robase-utils 2.3.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.
@@ -0,0 +1,481 @@
1
+ """
2
+ roboat_utils.async_client
3
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~
4
+ Async version of RoboatClient using aiohttp.
5
+
6
+ Bug fixes vs original:
7
+ - AsyncRoboatClient.__init__ accepted `cookie` param but stored nothing on error
8
+ - _handle used resp.ok which doesn't exist on aiohttp (use resp.status)
9
+ - All sub-API classes referenced undefined `Optional` without import
10
+ - get_servers used BASE (v1) but passed server_type in URL path — fixed
11
+ - get_user_games used BASE2 but the class only defined BASE — added BASE2
12
+
13
+ Requires: pip install aiohttp
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ from typing import Any, Dict, List, Optional
20
+
21
+ try:
22
+ import aiohttp
23
+ _AIOHTTP_AVAILABLE = True
24
+ except ImportError:
25
+ _AIOHTTP_AVAILABLE = False
26
+
27
+ from .models import (
28
+ User, Game, GameVotes, GameServer, Friend, Group, GroupRole,
29
+ Badge, CatalogItem, UserPresence, Avatar, RobuxBalance, Page,
30
+ )
31
+ from .exceptions import (
32
+ RateLimitedError, InvalidCookieError, UserNotFoundError,
33
+ GameNotFoundError, ItemNotFoundError, RoboatAPIError,
34
+ NotAuthenticatedError, raise_for_response,
35
+ )
36
+
37
+
38
+ class AsyncRoboatClient:
39
+ """
40
+ High-performance async Roblox API client using aiohttp.
41
+
42
+ Use as an async context manager::
43
+
44
+ async with AsyncRoboatClient(cookie="...") as client:
45
+ user = await client.users.get_user(156)
46
+
47
+ # Fan-out: fetch 1 000 users in parallel
48
+ user_ids = list(range(1, 1001))
49
+ users = await client.users.get_users_by_ids(user_ids)
50
+
51
+ # Parallel fetch with asyncio.gather
52
+ game, votes, icon = await asyncio.gather(
53
+ client.games.get_game(2753915549),
54
+ client.games.get_votes([2753915549]),
55
+ client.thumbnails.get_game_icons([2753915549]),
56
+ )
57
+
58
+ Or manage the session manually::
59
+
60
+ client = AsyncRoboatClient(cookie="...")
61
+ await client.start()
62
+ user = await client.users.get_user(156)
63
+ await client.close()
64
+ """
65
+
66
+ _BASE_HEADERS: Dict[str, str] = {
67
+ "Accept": "application/json",
68
+ "Content-Type": "application/json",
69
+ "User-Agent": "roboat-utils/3.0-async",
70
+ }
71
+
72
+ def __init__(self, cookie: Optional[str] = None, timeout: int = 10) -> None:
73
+ if not _AIOHTTP_AVAILABLE:
74
+ raise ImportError(
75
+ "aiohttp is required for AsyncRoboatClient. "
76
+ "Install it: pip install roboat-utils[async]"
77
+ )
78
+ self._cookie = cookie
79
+ self._timeout = aiohttp.ClientTimeout(total=timeout)
80
+ self._csrf_token: Optional[str] = None
81
+ self._session: Optional[aiohttp.ClientSession] = None
82
+
83
+ self.users = _AsyncUsersAPI(self)
84
+ self.games = _AsyncGamesAPI(self)
85
+ self.friends = _AsyncFriendsAPI(self)
86
+ self.catalog = _AsyncCatalogAPI(self)
87
+ self.groups = _AsyncGroupsAPI(self)
88
+ self.thumbnails = _AsyncThumbnailsAPI(self)
89
+ self.badges = _AsyncBadgesAPI(self)
90
+ self.presence = _AsyncPresenceAPI(self)
91
+ self.economy = _AsyncEconomyAPI(self)
92
+
93
+ # ── Lifecycle ─────────────────────────────────────────────────── #
94
+
95
+ async def start(self) -> None:
96
+ """Open the aiohttp session."""
97
+ cookies: Dict[str, str] = {}
98
+ if self._cookie:
99
+ cookies[".ROBLOSECURITY"] = self._cookie
100
+ self._session = aiohttp.ClientSession(
101
+ headers=self._BASE_HEADERS,
102
+ cookies=cookies,
103
+ timeout=self._timeout,
104
+ )
105
+ if self._cookie:
106
+ await self._refresh_csrf()
107
+
108
+ async def close(self) -> None:
109
+ """Close the aiohttp session."""
110
+ if self._session:
111
+ await self._session.close()
112
+ self._session = None
113
+
114
+ async def __aenter__(self) -> "AsyncRoboatClient":
115
+ await self.start()
116
+ return self
117
+
118
+ async def __aexit__(self, *_: Any) -> None:
119
+ await self.close()
120
+
121
+ # ── Auth ──────────────────────────────────────────────────────── #
122
+
123
+ async def _refresh_csrf(self) -> None:
124
+ try:
125
+ async with self._session.post( # type: ignore[union-attr]
126
+ "https://auth.roblox.com/v2/logout"
127
+ ) as r:
128
+ token = r.headers.get("x-csrf-token")
129
+ if token:
130
+ self._csrf_token = token
131
+ self._session.headers.update({"x-csrf-token": token}) # type: ignore[union-attr]
132
+ except Exception:
133
+ pass
134
+
135
+ def require_auth(self, method: str = "") -> None:
136
+ if not self._cookie:
137
+ raise NotAuthenticatedError(method)
138
+
139
+ # ── HTTP helpers ──────────────────────────────────────────────── #
140
+
141
+ async def _handle(self, resp: "aiohttp.ClientResponse") -> Any:
142
+ if resp.status == 403:
143
+ token = resp.headers.get("x-csrf-token")
144
+ if token and self._session:
145
+ self._csrf_token = token
146
+ self._session.headers.update({"x-csrf-token": token})
147
+ # caller will retry
148
+ return {}
149
+
150
+ if resp.status >= 400:
151
+ try:
152
+ body = await resp.json(content_type=None)
153
+ except Exception:
154
+ body = {}
155
+ raise_for_response(resp.status, body, url=str(resp.url))
156
+
157
+ try:
158
+ return await resp.json(content_type=None)
159
+ except Exception:
160
+ return {}
161
+
162
+ async def _get(self, url: str, **kwargs: Any) -> Any:
163
+ async with self._session.get(url, **kwargs) as r: # type: ignore[union-attr]
164
+ return await self._handle(r)
165
+
166
+ async def _post(self, url: str, **kwargs: Any) -> Any:
167
+ async with self._session.post(url, **kwargs) as r: # type: ignore[union-attr]
168
+ result = await self._handle(r)
169
+ if result == {} and r.status == 403:
170
+ async with self._session.post(url, **kwargs) as r2: # type: ignore[union-attr]
171
+ return await self._handle(r2)
172
+ return result
173
+
174
+ # ── Convenience shortcuts ─────────────────────────────────────── #
175
+
176
+ async def user_id(self) -> int:
177
+ self.require_auth("user_id")
178
+ data = await self._get("https://users.roblox.com/v1/users/authenticated")
179
+ return data["id"]
180
+
181
+ async def username(self) -> str:
182
+ self.require_auth("username")
183
+ data = await self._get("https://users.roblox.com/v1/users/authenticated")
184
+ return data["name"]
185
+
186
+ async def robux(self) -> int:
187
+ self.require_auth("robux")
188
+ uid = await self.user_id()
189
+ data = await self._get(f"https://economy.roblox.com/v1/users/{uid}/currency")
190
+ return data.get("robux", 0)
191
+
192
+ @property
193
+ def is_authenticated(self) -> bool:
194
+ return self._cookie is not None
195
+
196
+ def __repr__(self) -> str:
197
+ auth = "authenticated" if self.is_authenticated else "unauthenticated"
198
+ return f"<AsyncRoboatClient [{auth}]>"
199
+
200
+
201
+ # ── Sub-API implementations ───────────────────────────────────────── #
202
+
203
+ class _AsyncUsersAPI:
204
+ BASE = "https://users.roblox.com/v1"
205
+
206
+ def __init__(self, c: AsyncRoboatClient) -> None:
207
+ self._c = c
208
+
209
+ async def get_user(self, user_id: int) -> User:
210
+ data = await self._c._get(f"{self.BASE}/users/{user_id}")
211
+ return User.from_dict(data)
212
+
213
+ async def get_users_by_ids(
214
+ self, user_ids: List[int], exclude_banned: bool = False
215
+ ) -> List[User]:
216
+ results: List[User] = []
217
+ for i in range(0, len(user_ids), 100):
218
+ chunk = user_ids[i : i + 100]
219
+ data = await self._c._post(
220
+ f"{self.BASE}/users",
221
+ json={"userIds": chunk, "excludeBannedUsers": exclude_banned},
222
+ )
223
+ results.extend(User.from_dict(u) for u in data.get("data", []))
224
+ return results
225
+
226
+ async def get_users_by_usernames(self, usernames: List[str]) -> List[User]:
227
+ data = await self._c._post(
228
+ f"{self.BASE}/usernames/users",
229
+ json={"usernames": usernames, "excludeBannedUsers": False},
230
+ )
231
+ return [User.from_dict(u) for u in data.get("data", [])]
232
+
233
+ async def search_users(self, keyword: str, limit: int = 10) -> Page:
234
+ data = await self._c._get(
235
+ f"{self.BASE}/users/search",
236
+ params={"keyword": keyword, "limit": limit},
237
+ )
238
+ return Page.from_dict(data, User)
239
+
240
+
241
+ class _AsyncGamesAPI:
242
+ BASE = "https://games.roblox.com/v1"
243
+ BASE2 = "https://games.roblox.com/v2"
244
+
245
+ def __init__(self, c: AsyncRoboatClient) -> None:
246
+ self._c = c
247
+
248
+ async def get_games(self, universe_ids: List[int]) -> List[Game]:
249
+ data = await self._c._get(
250
+ f"{self.BASE}/games",
251
+ params={"universeIds": ",".join(str(i) for i in universe_ids)},
252
+ )
253
+ return [Game.from_dict(g) for g in data.get("data", [])]
254
+
255
+ async def get_game(self, universe_id: int) -> Game:
256
+ games = await self.get_games([universe_id])
257
+ if not games:
258
+ raise GameNotFoundError(universe_id)
259
+ return games[0]
260
+
261
+ async def get_votes(self, universe_ids: List[int]) -> List[GameVotes]:
262
+ data = await self._c._get(
263
+ f"{self.BASE}/games/votes",
264
+ params={"universeIds": ",".join(str(i) for i in universe_ids)},
265
+ )
266
+ return [GameVotes.from_dict(v) for v in data.get("data", [])]
267
+
268
+ async def get_servers(
269
+ self, place_id: int, server_type: str = "Public", limit: int = 10
270
+ ) -> Page:
271
+ data = await self._c._get(
272
+ f"{self.BASE}/games/{place_id}/servers/{server_type}",
273
+ params={"limit": limit},
274
+ )
275
+ return Page(
276
+ data=[GameServer.from_dict(s) for s in data.get("data", [])],
277
+ next_cursor=data.get("nextPageCursor"),
278
+ )
279
+
280
+ async def get_user_games(self, user_id: int, limit: int = 50) -> Page:
281
+ data = await self._c._get(
282
+ f"{self.BASE2}/users/{user_id}/games",
283
+ params={"limit": limit},
284
+ )
285
+ return Page.from_dict(data, Game)
286
+
287
+
288
+ class _AsyncFriendsAPI:
289
+ BASE = "https://friends.roblox.com/v1"
290
+
291
+ def __init__(self, c: AsyncRoboatClient) -> None:
292
+ self._c = c
293
+
294
+ async def get_friends(self, user_id: int) -> List[Friend]:
295
+ data = await self._c._get(f"{self.BASE}/users/{user_id}/friends")
296
+ return [Friend.from_dict(f) for f in data.get("data", [])]
297
+
298
+ async def get_friend_count(self, user_id: int) -> int:
299
+ data = await self._c._get(f"{self.BASE}/users/{user_id}/friends/count")
300
+ return data.get("count", 0)
301
+
302
+ async def get_follower_count(self, user_id: int) -> int:
303
+ data = await self._c._get(f"{self.BASE}/users/{user_id}/followers/count")
304
+ return data.get("count", 0)
305
+
306
+ async def get_followers(
307
+ self, user_id: int, limit: int = 100, cursor: Optional[str] = None
308
+ ) -> Page:
309
+ params: Dict[str, Any] = {"limit": limit}
310
+ if cursor:
311
+ params["cursor"] = cursor
312
+ data = await self._c._get(
313
+ f"{self.BASE}/users/{user_id}/followers", params=params
314
+ )
315
+ return Page.from_dict(data, Friend)
316
+
317
+
318
+ class _AsyncCatalogAPI:
319
+ BASE = "https://catalog.roblox.com/v1"
320
+
321
+ def __init__(self, c: AsyncRoboatClient) -> None:
322
+ self._c = c
323
+
324
+ async def search(
325
+ self,
326
+ keyword: str = "",
327
+ category: str = "All",
328
+ limit: int = 30,
329
+ cursor: Optional[str] = None,
330
+ ) -> Page:
331
+ params: Dict[str, Any] = {"category": category, "limit": limit}
332
+ if keyword:
333
+ params["keyword"] = keyword
334
+ if cursor:
335
+ params["cursor"] = cursor
336
+ data = await self._c._get(f"{self.BASE}/search/items", params=params)
337
+ return Page.from_dict(data, CatalogItem)
338
+
339
+ async def get_item_details(self, items: List[dict]) -> List[CatalogItem]:
340
+ data = await self._c._post(
341
+ f"{self.BASE}/catalog/items/details", json={"items": items}
342
+ )
343
+ return [CatalogItem.from_dict(i) for i in data.get("data", [])]
344
+
345
+
346
+ class _AsyncGroupsAPI:
347
+ BASE = "https://groups.roblox.com/v1"
348
+
349
+ def __init__(self, c: AsyncRoboatClient) -> None:
350
+ self._c = c
351
+
352
+ async def get_group(self, group_id: int) -> Group:
353
+ data = await self._c._get(f"{self.BASE}/groups/{group_id}")
354
+ return Group.from_dict(data)
355
+
356
+ async def get_roles(self, group_id: int) -> List[GroupRole]:
357
+ data = await self._c._get(f"{self.BASE}/groups/{group_id}/roles")
358
+ return [GroupRole.from_dict(r) for r in data.get("roles", [])]
359
+
360
+ async def get_members(
361
+ self, group_id: int, limit: int = 100, cursor: Optional[str] = None
362
+ ) -> Page:
363
+ params: Dict[str, Any] = {"limit": limit}
364
+ if cursor:
365
+ params["cursor"] = cursor
366
+ data = await self._c._get(f"{self.BASE}/groups/{group_id}/users", params=params)
367
+ return Page.from_dict(data)
368
+
369
+
370
+ class _AsyncThumbnailsAPI:
371
+ BASE = "https://thumbnails.roblox.com/v1"
372
+
373
+ def __init__(self, c: AsyncRoboatClient) -> None:
374
+ self._c = c
375
+
376
+ def _urls(self, data: dict) -> Dict[int, str]:
377
+ return {
378
+ item["targetId"]: item.get("imageUrl", "")
379
+ for item in data.get("data", [])
380
+ if item.get("state") == "Completed"
381
+ }
382
+
383
+ async def get_user_avatars(
384
+ self, user_ids: List[int], size: str = "420x420"
385
+ ) -> Dict[int, str]:
386
+ data = await self._c._get(
387
+ f"{self.BASE}/users/avatar",
388
+ params={
389
+ "userIds": ",".join(str(i) for i in user_ids),
390
+ "size": size,
391
+ "format": "Png",
392
+ },
393
+ )
394
+ return self._urls(data)
395
+
396
+ async def get_game_icons(
397
+ self, universe_ids: List[int], size: str = "512x512"
398
+ ) -> Dict[int, str]:
399
+ data = await self._c._get(
400
+ f"{self.BASE}/games/icons",
401
+ params={
402
+ "universeIds": ",".join(str(i) for i in universe_ids),
403
+ "size": size,
404
+ "format": "Png",
405
+ },
406
+ )
407
+ return self._urls(data)
408
+
409
+ async def get_asset_thumbnails(
410
+ self, asset_ids: List[int], size: str = "420x420"
411
+ ) -> Dict[int, str]:
412
+ data = await self._c._get(
413
+ f"{self.BASE}/assets",
414
+ params={
415
+ "assetIds": ",".join(str(i) for i in asset_ids),
416
+ "size": size,
417
+ "format": "Png",
418
+ },
419
+ )
420
+ return self._urls(data)
421
+
422
+
423
+ class _AsyncBadgesAPI:
424
+ BASE = "https://badges.roblox.com/v1"
425
+
426
+ def __init__(self, c: AsyncRoboatClient) -> None:
427
+ self._c = c
428
+
429
+ async def get_badge(self, badge_id: int) -> Badge:
430
+ data = await self._c._get(f"{self.BASE}/badges/{badge_id}")
431
+ return Badge.from_dict(data)
432
+
433
+ async def get_universe_badges(self, universe_id: int, limit: int = 10) -> Page:
434
+ data = await self._c._get(
435
+ f"{self.BASE}/universes/{universe_id}/badges", params={"limit": limit}
436
+ )
437
+ return Page.from_dict(data, Badge)
438
+
439
+ async def get_user_badges(self, user_id: int, limit: int = 100) -> Page:
440
+ data = await self._c._get(
441
+ f"{self.BASE}/users/{user_id}/badges", params={"limit": limit}
442
+ )
443
+ return Page.from_dict(data, Badge)
444
+
445
+
446
+ class _AsyncPresenceAPI:
447
+ BASE = "https://presence.roblox.com/v1"
448
+
449
+ def __init__(self, c: AsyncRoboatClient) -> None:
450
+ self._c = c
451
+
452
+ async def get_presences(self, user_ids: List[int]) -> List[UserPresence]:
453
+ data = await self._c._post(
454
+ f"{self.BASE}/presence/users", json={"userIds": user_ids}
455
+ )
456
+ return [UserPresence.from_dict(p) for p in data.get("userPresences", [])]
457
+
458
+ async def get_presence(self, user_id: int) -> UserPresence:
459
+ results = await self.get_presences([user_id])
460
+ return results[0] if results else UserPresence(user_id=user_id)
461
+
462
+
463
+ class _AsyncEconomyAPI:
464
+ BASE = "https://economy.roblox.com/v1"
465
+
466
+ def __init__(self, c: AsyncRoboatClient) -> None:
467
+ self._c = c
468
+
469
+ async def get_robux_balance(self, user_id: int) -> RobuxBalance:
470
+ self._c.require_auth("get_robux_balance")
471
+ data = await self._c._get(f"{self.BASE}/users/{user_id}/currency")
472
+ return RobuxBalance(robux=data.get("robux", 0))
473
+
474
+ async def get_asset_resale_data(self, asset_id: int) -> dict:
475
+ return await self._c._get(f"{self.BASE}/assets/{asset_id}/resale-data")
476
+
477
+ async def get_asset_resellers(self, asset_id: int, limit: int = 10) -> Page:
478
+ data = await self._c._get(
479
+ f"{self.BASE}/assets/{asset_id}/resellers", params={"limit": limit}
480
+ )
481
+ return Page.from_dict(data)
roboat_utils/avatar.py ADDED
@@ -0,0 +1,45 @@
1
+ """
2
+ roboat.avatar
3
+ ~~~~~~~~~~~~~~~~
4
+ Avatar API — avatar.roblox.com
5
+ """
6
+
7
+ from roboat_utils.models import Avatar, Page
8
+
9
+
10
+ class AvatarAPI:
11
+ BASE = "https://avatar.roblox.com/v1"
12
+ BASE2 = "https://avatar.roblox.com/v2"
13
+
14
+ def __init__(self, client):
15
+ self._c = client
16
+
17
+ def get_user_avatar(self, user_id: int) -> Avatar:
18
+ """Get the full avatar for a user."""
19
+ data = self._c._get(f"{self.BASE}/users/{user_id}/avatar")
20
+ return Avatar.from_dict(user_id, data)
21
+
22
+ def get_authenticated_avatar(self) -> Avatar:
23
+ """Get the authenticated user's avatar. Requires auth."""
24
+ self._c.require_auth("get_authenticated_avatar")
25
+ uid = self._c.users.get_authenticated_user()["id"]
26
+ data = self._c._get(f"{self.BASE}/avatar")
27
+ return Avatar.from_dict(uid, data)
28
+
29
+ def get_avatar_rules(self) -> dict:
30
+ """Get all avatar rules (allowed scales, asset types, etc.)."""
31
+ return self._c._get(f"{self.BASE}/avatar-rules")
32
+
33
+ def get_outfit(self, outfit_id: int) -> dict:
34
+ """Get details for a saved outfit."""
35
+ return self._c._get(f"{self.BASE}/outfits/{outfit_id}/details")
36
+
37
+ def get_user_outfits(self, user_id: int, page: int = 1,
38
+ items_per_page: int = 25,
39
+ is_editable: bool = False) -> dict:
40
+ """Get outfits for a user."""
41
+ return self._c._get(
42
+ f"{self.BASE}/users/{user_id}/outfits",
43
+ params={"page": page, "itemsPerPage": items_per_page,
44
+ "isEditable": is_editable},
45
+ )
roboat_utils/badges.py ADDED
@@ -0,0 +1,50 @@
1
+ """
2
+ roboat.badges
3
+ ~~~~~~~~~~~~~~~~
4
+ Badges API — badges.roblox.com
5
+ """
6
+
7
+ from typing import List, Optional
8
+ from roboat_utils.models import Badge, Page
9
+
10
+
11
+ class BadgesAPI:
12
+ BASE = "https://badges.roblox.com/v1"
13
+
14
+ def __init__(self, client):
15
+ self._c = client
16
+
17
+ def get_badge(self, badge_id: int) -> Badge:
18
+ """Get badge info by ID."""
19
+ data = self._c._get(f"{self.BASE}/badges/{badge_id}")
20
+ return Badge.from_dict(data)
21
+
22
+ def get_universe_badges(self, universe_id: int, limit: int = 10,
23
+ cursor: Optional[str] = None) -> Page:
24
+ """Get all badges for a universe/game."""
25
+ params = {"limit": limit, "sortOrder": "Asc"}
26
+ if cursor: params["cursor"] = cursor
27
+ data = self._c._get(f"{self.BASE}/universes/{universe_id}/badges", params=params)
28
+ return Page.from_dict(data, Badge)
29
+
30
+ def get_user_badges(self, user_id: int, limit: int = 10,
31
+ cursor: Optional[str] = None) -> Page:
32
+ """Get badges awarded to a user."""
33
+ params = {"limit": limit, "sortOrder": "Asc"}
34
+ if cursor: params["cursor"] = cursor
35
+ data = self._c._get(f"{self.BASE}/users/{user_id}/badges", params=params)
36
+ return Page.from_dict(data, Badge)
37
+
38
+ def get_awarded_dates(self, user_id: int, badge_ids: List[int]) -> dict:
39
+ """
40
+ Check which badges a user has and when they were awarded.
41
+ Returns {badge_id: awarded_date_str}.
42
+ """
43
+ data = self._c._get(
44
+ f"{self.BASE}/users/{user_id}/badges/awarded-dates",
45
+ params={"badgeIds": ",".join(str(i) for i in badge_ids)},
46
+ )
47
+ return {
48
+ item["badgeId"]: item.get("awardedDate")
49
+ for item in data.get("data", [])
50
+ }
@@ -0,0 +1,81 @@
1
+ """
2
+ roboat.catalog
3
+ ~~~~~~~~~~~~~~~~~
4
+ Catalog API — catalog.roblox.com
5
+ """
6
+
7
+ from typing import List, Optional
8
+ from roboat_utils.models import CatalogItem, Page
9
+
10
+
11
+ class CatalogAPI:
12
+ BASE = "https://catalog.roblox.com/v1"
13
+ BASE2 = "https://catalog.roblox.com/v2"
14
+
15
+ def __init__(self, client):
16
+ self._c = client
17
+
18
+ def search(self, keyword: str = "", category: str = "All",
19
+ subcategory: str = "All", sort_type: str = "Relevance",
20
+ price_min: Optional[int] = None, price_max: Optional[int] = None,
21
+ limit: int = 30, cursor: Optional[str] = None) -> Page:
22
+ """Search the avatar catalog."""
23
+ params = {
24
+ "category": category, "subcategory": subcategory,
25
+ "sortType": sort_type, "limit": limit,
26
+ }
27
+ if keyword: params["keyword"] = keyword
28
+ if price_min: params["minPrice"] = price_min
29
+ if price_max: params["maxPrice"] = price_max
30
+ if cursor: params["cursor"] = cursor
31
+ data = self._c._get(f"{self.BASE}/search/items", params=params)
32
+ return Page.from_dict(data, CatalogItem)
33
+
34
+ def get_item_details(self, items: List[dict]) -> List[CatalogItem]:
35
+ """
36
+ Get details for catalog items.
37
+ items: list of {"itemType": "Asset"|"Bundle", "id": int}
38
+ """
39
+ data = self._c._post(
40
+ f"{self.BASE}/catalog/items/details",
41
+ json={"items": items},
42
+ )
43
+ return [CatalogItem.from_dict(i) for i in data.get("data", [])]
44
+
45
+ def get_asset(self, asset_id: int) -> CatalogItem:
46
+ """Get details for a single asset."""
47
+ results = self.get_item_details([{"itemType": "Asset", "id": asset_id}])
48
+ if not results:
49
+ from roboat_utils.exceptions import ItemNotFoundError
50
+ raise ItemNotFoundError(f"Asset {asset_id} not found")
51
+ return results[0]
52
+
53
+ def get_bundle(self, bundle_id: int) -> CatalogItem:
54
+ """Get details for a single bundle."""
55
+ results = self.get_item_details([{"itemType": "Bundle", "id": bundle_id}])
56
+ if not results:
57
+ from roboat_utils.exceptions import ItemNotFoundError
58
+ raise ItemNotFoundError(f"Bundle {bundle_id} not found")
59
+ return results[0]
60
+
61
+ def get_resale_data(self, asset_id: int):
62
+ """Get resale history for a limited item. Delegates to economy API."""
63
+ return self._c.economy.get_asset_resale_data(asset_id)
64
+
65
+ def get_resellers(self, asset_id: int, limit: int = 10,
66
+ cursor: Optional[str] = None) -> Page:
67
+ """Get resellers for a limited item."""
68
+ return self._c.economy.get_asset_resellers(asset_id, limit, cursor)
69
+
70
+ def get_user_bundles(self, user_id: int, limit: int = 10,
71
+ cursor: Optional[str] = None) -> Page:
72
+ """Get bundles owned by a user."""
73
+ params = {"limit": limit}
74
+ if cursor: params["cursor"] = cursor
75
+ data = self._c._get(f"{self.BASE}/users/{user_id}/bundles", params=params)
76
+ return Page.from_dict(data)
77
+
78
+ def get_asset_favorite_count(self, asset_id: int) -> int:
79
+ """Get favorite count for an asset."""
80
+ data = self._c._get(f"{self.BASE}/favorites/assets/{asset_id}/count")
81
+ return data if isinstance(data, int) else 0