rbx-upload 0.1.0__tar.gz
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.
- rbx_upload-0.1.0/.claude/settings.local.json +8 -0
- rbx_upload-0.1.0/.gitignore +7 -0
- rbx_upload-0.1.0/PKG-INFO +7 -0
- rbx_upload-0.1.0/pyproject.toml +16 -0
- rbx_upload-0.1.0/src/rbx_upload/__init__.py +11 -0
- rbx_upload-0.1.0/src/rbx_upload/client.py +248 -0
- rbx_upload-0.1.0/src/rbx_upload/models.py +56 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "rbx-upload"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Roblox asset upload client"
|
|
5
|
+
requires-python = ">=3.13"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"httpx>=0.25.0",
|
|
8
|
+
]
|
|
9
|
+
license = "MIT"
|
|
10
|
+
|
|
11
|
+
[build-system]
|
|
12
|
+
requires = ["hatchling"]
|
|
13
|
+
build-backend = "hatchling.build"
|
|
14
|
+
|
|
15
|
+
[tool.hatch.build.targets.wheel]
|
|
16
|
+
packages = ["src/rbx_upload"]
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import uuid
|
|
4
|
+
import xml.etree.ElementTree
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .models import ClothingAsset, RbxAsset, RbxAssetType, RbxCreator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RateLimitError(Exception):
|
|
12
|
+
"""Raised when hitting Roblox rate limits (HTTP 429)."""
|
|
13
|
+
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RobloxClient:
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
roblosecurity: str,
|
|
21
|
+
publisher_user_id: int,
|
|
22
|
+
proxy: str | None = None,
|
|
23
|
+
):
|
|
24
|
+
self._roblosecurity = roblosecurity
|
|
25
|
+
self._publisher_user_id = publisher_user_id
|
|
26
|
+
self._proxy = proxy
|
|
27
|
+
self._http = httpx.AsyncClient()
|
|
28
|
+
|
|
29
|
+
self._fetch_headers = {
|
|
30
|
+
"Cookie": f".ROBLOSECURITY={roblosecurity}",
|
|
31
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
|
32
|
+
}
|
|
33
|
+
self._csrf_headers = {
|
|
34
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:145.0) Gecko/20100101 Firefox/145.0",
|
|
35
|
+
"Referer": "https://create.roblox.com/",
|
|
36
|
+
"Origin": "https://create.roblox.com",
|
|
37
|
+
}
|
|
38
|
+
self._csrf_cookies = {".ROBLOSECURITY": roblosecurity}
|
|
39
|
+
|
|
40
|
+
def _proxy_url(self, url: str) -> str:
|
|
41
|
+
if not self._proxy:
|
|
42
|
+
return url
|
|
43
|
+
return url.replace("roblox.com", self._proxy)
|
|
44
|
+
|
|
45
|
+
async def _get_csrf_token(self) -> str:
|
|
46
|
+
url = self._proxy_url("https://apis.roblox.com/assets/user-auth/v1/assets")
|
|
47
|
+
response = await self._http.post(
|
|
48
|
+
url, cookies=self._csrf_cookies, headers=self._csrf_headers
|
|
49
|
+
)
|
|
50
|
+
csrf = response.headers.get("X-CSRF-TOKEN")
|
|
51
|
+
if not csrf:
|
|
52
|
+
raise httpx.HTTPStatusError(
|
|
53
|
+
"Failed to retrieve X-CSRF-TOKEN.",
|
|
54
|
+
request=response.request,
|
|
55
|
+
response=response,
|
|
56
|
+
)
|
|
57
|
+
return csrf
|
|
58
|
+
|
|
59
|
+
async def _economy_request(self, asset_id: int) -> httpx.Response:
|
|
60
|
+
url = self._proxy_url(
|
|
61
|
+
f"https://economy.roblox.com/v2/assets/{asset_id}/details"
|
|
62
|
+
)
|
|
63
|
+
return await self._http.get(url, headers=self._fetch_headers)
|
|
64
|
+
|
|
65
|
+
async def _asset_delivery_request(self, asset_id: int) -> httpx.Response:
|
|
66
|
+
url = self._proxy_url(
|
|
67
|
+
f"https://assetdelivery.roblox.com/v1/asset/?id={asset_id}"
|
|
68
|
+
)
|
|
69
|
+
return await self._http.get(
|
|
70
|
+
url, headers=self._fetch_headers, follow_redirects=True
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
async def _get_asset_xml(self, asset: RbxAsset) -> xml.etree.ElementTree.Element:
|
|
74
|
+
response = await self._asset_delivery_request(asset.asset_id)
|
|
75
|
+
response.raise_for_status()
|
|
76
|
+
content = response.content.decode("utf-8")
|
|
77
|
+
return xml.etree.ElementTree.fromstring(content)
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _get_shirt_template_id_from_xml(root: xml.etree.ElementTree.Element) -> int:
|
|
81
|
+
url_element = root.find(".//url")
|
|
82
|
+
if url_element is None:
|
|
83
|
+
raise ValueError("XML did not contain a <url> tag.")
|
|
84
|
+
url = url_element.text
|
|
85
|
+
if not url:
|
|
86
|
+
raise ValueError("<url> tag did not contain any text.")
|
|
87
|
+
return int(url.split("id=")[1])
|
|
88
|
+
|
|
89
|
+
async def asset_from_id(self, asset_id: int) -> RbxAsset:
|
|
90
|
+
"""Fetch asset information from Roblox by asset ID."""
|
|
91
|
+
response = await self._economy_request(asset_id)
|
|
92
|
+
response.raise_for_status()
|
|
93
|
+
asset_info = response.json()
|
|
94
|
+
creator_info = asset_info["Creator"]
|
|
95
|
+
creator = RbxCreator(
|
|
96
|
+
creator_id=creator_info["Id"],
|
|
97
|
+
username=creator_info["Name"],
|
|
98
|
+
creator_type=creator_info["CreatorType"],
|
|
99
|
+
)
|
|
100
|
+
asset_type_id = asset_info["AssetTypeId"]
|
|
101
|
+
if asset_type_id in (RbxAssetType.SHIRT, RbxAssetType.PANTS):
|
|
102
|
+
return ClothingAsset(
|
|
103
|
+
asset_id=asset_info["AssetId"],
|
|
104
|
+
creator=creator,
|
|
105
|
+
name=asset_info["Name"],
|
|
106
|
+
description=asset_info["Description"],
|
|
107
|
+
asset_type=asset_type_id,
|
|
108
|
+
)
|
|
109
|
+
return RbxAsset(
|
|
110
|
+
asset_id=asset_info["AssetId"],
|
|
111
|
+
creator=creator,
|
|
112
|
+
name=asset_info["Name"],
|
|
113
|
+
description=asset_info["Description"],
|
|
114
|
+
asset_type=asset_type_id,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
async def fetch_clothing_image(self, asset: ClothingAsset) -> bytes:
|
|
118
|
+
"""Fetch the image data for a clothing asset."""
|
|
119
|
+
xml_root = await self._get_asset_xml(asset)
|
|
120
|
+
template_id = self._get_shirt_template_id_from_xml(xml_root)
|
|
121
|
+
image = await self._asset_delivery_request(template_id)
|
|
122
|
+
image.raise_for_status()
|
|
123
|
+
return image.content
|
|
124
|
+
|
|
125
|
+
async def upload_clothing_image(
|
|
126
|
+
self,
|
|
127
|
+
image: bytes,
|
|
128
|
+
name: str,
|
|
129
|
+
description: str,
|
|
130
|
+
asset_type: RbxAssetType,
|
|
131
|
+
group_id: int,
|
|
132
|
+
) -> dict:
|
|
133
|
+
"""Upload a clothing image to Roblox and return the operation result."""
|
|
134
|
+
csrf = await self._get_csrf_token()
|
|
135
|
+
upload_url = self._proxy_url(
|
|
136
|
+
"https://apis.roblox.com/assets/user-auth/v1/assets"
|
|
137
|
+
)
|
|
138
|
+
meta = {
|
|
139
|
+
"displayName": name,
|
|
140
|
+
"description": description,
|
|
141
|
+
"assetType": asset_type,
|
|
142
|
+
"creationContext": {
|
|
143
|
+
"creator": {"groupId": group_id},
|
|
144
|
+
"expectedPrice": 10,
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
upload_headers = {
|
|
148
|
+
"X-CSRF-TOKEN": csrf,
|
|
149
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:145.0) Gecko/20100101 Firefox/145.0",
|
|
150
|
+
"Accept": "*/*",
|
|
151
|
+
"Accept-Language": "en-US,en;q=0.5",
|
|
152
|
+
"Referer": "https://create.roblox.com/",
|
|
153
|
+
"Origin": "https://create.roblox.com",
|
|
154
|
+
"Sec-Fetch-Dest": "empty",
|
|
155
|
+
"Sec-Fetch-Mode": "cors",
|
|
156
|
+
"Sec-Fetch-Site": "same-site",
|
|
157
|
+
}
|
|
158
|
+
response = await self._http.post(
|
|
159
|
+
upload_url,
|
|
160
|
+
files={
|
|
161
|
+
"request": (None, json.dumps(meta), "application/json"),
|
|
162
|
+
"fileContent": ("clothing_upload", image, "image/png"),
|
|
163
|
+
},
|
|
164
|
+
headers=upload_headers,
|
|
165
|
+
cookies=self._csrf_cookies,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
if response.status_code == 429:
|
|
169
|
+
raise RateLimitError("Rate limit hit during upload")
|
|
170
|
+
|
|
171
|
+
response.raise_for_status()
|
|
172
|
+
data = response.json()
|
|
173
|
+
|
|
174
|
+
operation_id = data.get("operationId")
|
|
175
|
+
if operation_id:
|
|
176
|
+
for _ in range(10):
|
|
177
|
+
await asyncio.sleep(1)
|
|
178
|
+
op_response = await self._http.get(
|
|
179
|
+
self._proxy_url(
|
|
180
|
+
f"https://apis.roblox.com/assets/user-auth/v1/operations/{operation_id}"
|
|
181
|
+
),
|
|
182
|
+
headers={"X-CSRF-TOKEN": csrf},
|
|
183
|
+
cookies=self._csrf_cookies,
|
|
184
|
+
)
|
|
185
|
+
op_response.raise_for_status()
|
|
186
|
+
op_data = op_response.json()
|
|
187
|
+
if op_data.get("done"):
|
|
188
|
+
if op_data.get("response", {}).get("assetId"):
|
|
189
|
+
return {"asset_id": op_data["response"]["assetId"]}
|
|
190
|
+
return op_data
|
|
191
|
+
|
|
192
|
+
return data
|
|
193
|
+
|
|
194
|
+
async def onsale_asset(
|
|
195
|
+
self,
|
|
196
|
+
asset_id: int,
|
|
197
|
+
name: str,
|
|
198
|
+
description: str,
|
|
199
|
+
group_id: int,
|
|
200
|
+
price: int = 5,
|
|
201
|
+
) -> dict:
|
|
202
|
+
"""Put an asset on sale as a collectible."""
|
|
203
|
+
csrf = await self._get_csrf_token()
|
|
204
|
+
data = {
|
|
205
|
+
"saleLocationConfiguration": {"saleLocationType": 1, "places": []},
|
|
206
|
+
"targetId": asset_id,
|
|
207
|
+
"priceInRobux": price,
|
|
208
|
+
"publishingType": 2,
|
|
209
|
+
"idempotencyToken": str(uuid.uuid4()),
|
|
210
|
+
"publisherUserId": self._publisher_user_id,
|
|
211
|
+
"creatorGroupId": group_id,
|
|
212
|
+
"name": name,
|
|
213
|
+
"description": description,
|
|
214
|
+
"isFree": False,
|
|
215
|
+
"agreedPublishingFee": 0,
|
|
216
|
+
"priceOffset": 0,
|
|
217
|
+
"quantity": 0,
|
|
218
|
+
"quantityLimitPerUser": 0,
|
|
219
|
+
"resaleRestriction": 2,
|
|
220
|
+
"targetType": 0,
|
|
221
|
+
}
|
|
222
|
+
response = await self._http.post(
|
|
223
|
+
self._proxy_url("https://itemconfiguration.roblox.com/v1/collectibles"),
|
|
224
|
+
json=data,
|
|
225
|
+
headers={
|
|
226
|
+
"X-CSRF-TOKEN": csrf,
|
|
227
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:145.0) Gecko/20100101 Firefox/145.0",
|
|
228
|
+
"Referer": "https://create.roblox.com/",
|
|
229
|
+
"Origin": "https://create.roblox.com",
|
|
230
|
+
},
|
|
231
|
+
cookies=self._csrf_cookies,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
if response.status_code == 429:
|
|
235
|
+
raise RateLimitError("Rate limit hit during onsale")
|
|
236
|
+
|
|
237
|
+
response.raise_for_status()
|
|
238
|
+
return response.json()
|
|
239
|
+
|
|
240
|
+
async def close(self):
|
|
241
|
+
"""Close the underlying HTTP client."""
|
|
242
|
+
await self._http.aclose()
|
|
243
|
+
|
|
244
|
+
async def __aenter__(self):
|
|
245
|
+
return self
|
|
246
|
+
|
|
247
|
+
async def __aexit__(self, *args):
|
|
248
|
+
await self.close()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from enum import IntEnum
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class RbxAssetType(IntEnum):
|
|
6
|
+
IMAGE = 1
|
|
7
|
+
SHIRT = 11
|
|
8
|
+
PANTS = 12
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
ClothingAssetType = Literal[
|
|
12
|
+
RbxAssetType.SHIRT,
|
|
13
|
+
RbxAssetType.PANTS,
|
|
14
|
+
]
|
|
15
|
+
CreatorType = Literal["User", "Group"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RbxCreator:
|
|
19
|
+
def __init__(self, creator_id: int, username: str, creator_type: CreatorType):
|
|
20
|
+
self.creator_id = creator_id
|
|
21
|
+
self.username = username
|
|
22
|
+
self.creator_type = creator_type
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RbxAsset:
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
asset_id: int,
|
|
29
|
+
creator: RbxCreator,
|
|
30
|
+
name: str,
|
|
31
|
+
description: str,
|
|
32
|
+
asset_type: RbxAssetType,
|
|
33
|
+
) -> None:
|
|
34
|
+
self.asset_id = asset_id
|
|
35
|
+
self.name = name
|
|
36
|
+
self.description = description
|
|
37
|
+
self.creator = creator
|
|
38
|
+
self.asset_type = asset_type
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ClothingAsset(RbxAsset):
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
asset_id: int,
|
|
45
|
+
creator: RbxCreator,
|
|
46
|
+
name: str,
|
|
47
|
+
description: str,
|
|
48
|
+
asset_type: ClothingAssetType,
|
|
49
|
+
) -> None:
|
|
50
|
+
super().__init__(
|
|
51
|
+
asset_id=asset_id,
|
|
52
|
+
creator=creator,
|
|
53
|
+
name=name,
|
|
54
|
+
description=description,
|
|
55
|
+
asset_type=asset_type,
|
|
56
|
+
)
|