Last active
August 19, 2025 20:53
-
-
Save exurd/36f2803b379abd13ca976f68a7567f71 to your computer and use it in GitHub Desktop.
Roblox: Save inventory-owned assets to the Creator Store "Saved" list.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """ | |
| Save inventory-owned assets to the Creator Store "Saved" list. (19/08/2025) | |
| """ | |
| import os | |
| import time | |
| import math | |
| from dotenv import load_dotenv # pip install python-dotenv | |
| import requests # pip install requests | |
| # Path to .env file with `RBX_TOKEN`: | |
| # https://github.com/exurd/DBR/blob/main/.example_env | |
| ENV_FILEPATH = "/PATH/TO/.env" | |
| # Save your own assets to the list? | |
| SAVE_OWN_ASSETS = False | |
| # Which asset types to save? | |
| ASSET_TYPES = { | |
| 3: "Audio", | |
| 10: "Model", | |
| 13: "Decal", | |
| 38: "Plugin", | |
| 40: "MeshPart", | |
| 62: "Video" | |
| } | |
| def validate_CSRF() -> str: | |
| """ | |
| Gets X-CSRF-Token for Roblox. | |
| """ | |
| requestSession.headers["X-CSRF-Token"] = None | |
| req = requestSession.post(url="https://auth.roblox.com/v2/logout", timeout=60) | |
| return req.headers["X-CSRF-Token"] | |
| if not os.path.isfile(ENV_FILEPATH): | |
| raise Exception(f".env file \"{ENV_FILEPATH}\" was not found") | |
| load_dotenv(ENV_FILEPATH) | |
| requestSession = requests.Session() | |
| adapter = requests.adapters.HTTPAdapter(max_retries=5) | |
| requestSession.mount('https://', adapter) | |
| requestSession.mount('http://', adapter) | |
| requestSession.cookies[".ROBLOSECURITY"] = str(os.getenv("RBX_TOKEN")) | |
| requestSession.headers['Referer'] = "https://www.roblox.com" | |
| requestSession.headers["X-CSRF-Token"] = validate_CSRF() | |
| print("Loaded .env file!") | |
| def get_user_from_token() -> dict: | |
| """ | |
| Gets user ID from .ROBLOSECURITY token. | |
| Results are not cached. | |
| """ | |
| usercheck = requestSession.get("https://users.roblox.com/v1/users/authenticated") | |
| if usercheck is not None: | |
| if usercheck.ok: | |
| return usercheck.json() | |
| return None | |
| MAIN_USER_ID = get_user_from_token() | |
| if MAIN_USER_ID is None: | |
| raise Exception("User ID could not be requested.") | |
| MAIN_USER_ID = MAIN_USER_ID["id"] | |
| SAVEAPI_HEADERS = { | |
| "accept": "*/*", | |
| "cache-control": "no-cache", | |
| "content-type": "application/json-patch+json", | |
| "dnt": "1", | |
| "origin": "https://create.roblox.com", | |
| "pragma": "no-cache", | |
| "priority": "u=1, i", | |
| "referer": "https://create.roblox.com/", | |
| "sec-fetch-dest": "empty", | |
| "sec-fetch-mode": "cors", | |
| "sec-fetch-site": "same-site" | |
| } | |
| def request_save_asset(asset_id:int, asset_type:str): | |
| """ | |
| Saves asset to saved list. | |
| HTTP codes I encountered: | |
| - 403 Forbidden | |
| - Fixed by updating the CSRF token | |
| - 415 Unsupported Media Type | |
| - Added headers for "content-type" | |
| - 400 Bad Request | |
| - Used Bruno to get as accurate to the original request (minus cookies and other bulk) | |
| - Didn't work... | |
| - `{"errors":{"":["Error parsing boolean value. Path '', line 1, position 1."],"request":["The request field is required."]},"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"traceId":"00-b1fc714eefb8ac3f2b79b892a37db2db-2620be73f64ea483-00"}` | |
| - hmm... | |
| - Fixed! requestSession.post: Switched from `data=payload` to `json=payload` | |
| - 409 Conflict | |
| - "Save already exists" | |
| """ | |
| url = f"https://apis.roblox.com/toolbox-service/v1/saves" | |
| payload = { | |
| "targetType": asset_type, | |
| "targetId": str(asset_id) | |
| } | |
| for _ in range(5): | |
| response = requestSession.post(url, json=payload, headers=SAVEAPI_HEADERS) | |
| print(f"Response code is {response.status_code}!") | |
| if response.status_code == 201: # Created | |
| print("Saved!") | |
| return True | |
| if response.status_code == 403: # Conflict | |
| print("Already saved!") | |
| return True | |
| if response.status_code == 403: # Forbidden | |
| print("Refreshing CSRF token...") | |
| requestSession.headers["X-CSRF-Token"] = validate_CSRF() | |
| print("Sleeping 5 secs...") | |
| time.sleep(5) | |
| print(f"Could not save asset {asset_id} as {asset_type}") | |
| return False | |
| def check_if_saved(asset_id:int, asset_type:str): | |
| """ | |
| Response JSON: | |
| { | |
| "totalCount": 0, | |
| "saves": [] | |
| } | |
| """ | |
| url = f"https://apis.roblox.com/toolbox-service/v1/saves?targetType={asset_type}&targetId={str(asset_id)}" | |
| response = requestSession.get(url, headers=SAVEAPI_HEADERS) | |
| if response.ok: | |
| response = response.json() | |
| if response["totalCount"] != 0: | |
| return True | |
| return False | |
| def get_economy_info(asset_id:int): | |
| """ | |
| Gets the info of an asset. | |
| Response JSON of Economy details API: | |
| { | |
| "TargetId": 136176178143668, | |
| "ProductType": "User Product", | |
| "AssetId": 136176178143668, | |
| "ProductId": 3330191709, | |
| "Name": "CrazyTracer-OPEN", | |
| "Description": "Open source raytracing project!", | |
| "AssetTypeId": 10, | |
| "Creator": { | |
| "Id": 247535, | |
| "Name": "Crazyblox", | |
| "CreatorType": "User", | |
| "CreatorTargetId": 247535, | |
| "HasVerifiedBadge": true | |
| }, | |
| "IconImageAssetId": 0, | |
| "Created": "2025-07-10T17:40:45.157Z", | |
| "Updated": "2025-07-10T17:40:45.16Z", | |
| "PriceInRobux": null, | |
| "PriceInTickets": null, | |
| "Sales": 0, | |
| "IsNew": false, | |
| "IsForSale": false, | |
| "IsPublicDomain": true, | |
| "IsLimited": false, | |
| "IsLimitedUnique": false, | |
| "Remaining": null, | |
| "MinimumMembershipLevel": 0, | |
| "ContentRatingTypeId": 0, | |
| "SaleAvailabilityLocations": null, | |
| "SaleLocation": null, | |
| "CollectibleItemId": null, | |
| "CollectibleProductId": null, | |
| "CollectiblesItemDetails": null | |
| } | |
| """ | |
| url = f"https://economy.roblox.com/v2/assets/{str(asset_id)}/details" | |
| response = requestSession.get(url) | |
| if response.ok: | |
| response = response.json() | |
| return response | |
| print(f"Could not get asset info! Status code: {response.status_code}") | |
| return False | |
| def save_asset(asset_id:int, asset_type=""): | |
| """Saves assets.""" | |
| if asset_id and asset_type != "": | |
| return request_save_asset(asset_id, asset_type) | |
| if asset_details := get_economy_info(asset_id): | |
| if not SAVE_OWN_ASSETS and int(asset_details["Creator"]["Id"]) == MAIN_USER_ID: | |
| print("Owner is account in session; skipping...") | |
| return False | |
| at_id = asset_details["AssetTypeId"] | |
| asset_type = ASSET_TYPES[at_id] if at_id in ASSET_TYPES else False | |
| if asset_type: | |
| if check_if_saved(asset_id, asset_type): | |
| print("Already saved!") | |
| return False | |
| return request_save_asset(asset_id, asset_type) | |
| return False | |
| def loop_through_inv(asset_type_id, user_id=MAIN_USER_ID): | |
| page_cursor = "" | |
| while True: | |
| url = f"https://inventory.roblox.com/v2/users/{str(user_id)}/inventory/{str(asset_type_id)}?cursor=&limit=100&sortOrder=Desc{page_cursor}" | |
| response = requestSession.get(url) | |
| if response.ok: | |
| response = response.json() | |
| for asset in response["data"]: | |
| asset_id = asset["assetId"] | |
| print(f"Attempting to save asset {asset_id} to profile...") | |
| save_asset(asset_id) | |
| if response["nextPageCursor"] is None: | |
| print("No more pages to search!") | |
| return True | |
| page_cursor = f"&cursor={response["nextPageCursor"]}" | |
| if __name__ == "__main__": | |
| for at_id in ASSET_TYPES: | |
| print("=" * 30) | |
| print(f"{' ' * math.floor((30 - len(ASSET_TYPES[at_id])) / 2)}{ASSET_TYPES[at_id]}") | |
| print("=" * 30) | |
| loop_through_inv(at_id) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment