Switch clothing fetch to itemconfiguration API, remove load more

- Replace catalog search (1000 item cap) with itemconfiguration/v1/creations/get-assets
- Batch-enrich with catalog/items/details and fetch thumbnails concurrently
- Compound base64 cursor handles shirts then pants pagination
- Add retry logic with backoff for all three Roblox API calls
- Remove load more button; auto-load all pages on group select
- Default sort to newest first
This commit is contained in:
2026-03-16 19:39:03 -04:00
parent c448ca8ba3
commit 1288a2238c
2 changed files with 99 additions and 44 deletions
+91 -33
View File
@@ -1,3 +1,6 @@
import asyncio
import base64
import json
import logging import logging
import time import time
@@ -9,6 +12,8 @@ from src.config import ROBLOSECURITY_TOKEN, PUBLISHER_USER_ID, ROBLOX_PROXY
_groups_cache: dict[str, tuple[float, list]] = {} _groups_cache: dict[str, tuple[float, list]] = {}
CACHE_TTL = 300 # 5 minutes CACHE_TTL = 300 # 5 minutes
MAX_RETRIES = 3
RETRY_DELAY = 3.0
async def get_uploadable_groups() -> list[dict]: async def get_uploadable_groups() -> list[dict]:
@@ -69,56 +74,109 @@ async def _get_csrf_token(client: httpx.AsyncClient) -> str:
return csrf return csrf
async def _retry(coro_fn, retries=MAX_RETRIES):
for attempt in range(retries):
try:
return await coro_fn()
except Exception:
if attempt == retries - 1:
raise
await asyncio.sleep(RETRY_DELAY * (attempt + 1))
def _encode_cursor(state: dict) -> str:
return base64.b64encode(json.dumps(state).encode()).decode()
def _decode_cursor(cursor: str) -> dict:
return json.loads(base64.b64decode(cursor).decode())
async def fetch_group_clothing(group_id: int, cursor: str = "") -> dict: async def fetch_group_clothing(group_id: int, cursor: str = "") -> dict:
url = _proxy_url("https://catalog.roblox.com/v1/search/items/details") state = _decode_cursor(cursor) if cursor else {"type": "Shirt", "cursor": ""}
params = { asset_type = state["type"]
"Category": 3, inner_cursor = state["cursor"]
"CreatorType": 2,
"CreatorTargetId": group_id,
"IncludeNotForSale": "true",
"Limit": 30,
}
if cursor:
params["Cursor"] = cursor
async with httpx.AsyncClient(cookies=_auth_cookies()) as client: async with httpx.AsyncClient(cookies=_auth_cookies()) as client:
resp = await client.get(url, params=params) # Step 1: get a page of asset IDs from itemconfiguration
id_params = {"assetType": asset_type, "groupId": group_id, "limit": 100}
if inner_cursor:
id_params["cursor"] = inner_cursor
async def _fetch_ids():
resp = await client.get(
_proxy_url("https://itemconfiguration.roblox.com/v1/creations/get-assets"),
params=id_params,
)
resp.raise_for_status() resp.raise_for_status()
data = resp.json() return resp.json()
raw_items = data.get("data", []) id_data = await _retry(_fetch_ids)
asset_ids = [item.get("id") for item in raw_items if item.get("id")] asset_ids = [item["assetId"] for item in id_data.get("data", [])]
next_inner_cursor = id_data.get("nextPageCursor", "")
thumbnails = {} if not asset_ids:
if asset_ids: if asset_type == "Shirt":
thumb_resp = await client.get( return {"items": [], "next_cursor": _encode_cursor({"type": "Pants", "cursor": ""})}
return {"items": [], "next_cursor": ""}
# Step 2+3: fetch details and thumbnails concurrently
async def _fetch_details():
csrf = await _get_csrf_token(client)
resp = await client.post(
_proxy_url("https://catalog.roblox.com/v1/catalog/items/details"),
json={"items": [{"itemType": "Asset", "id": i} for i in asset_ids]},
headers={"X-CSRF-TOKEN": csrf},
)
resp.raise_for_status()
by_id = {}
for item in resp.json().get("data", []):
by_id[item["id"]] = item
return by_id
async def _fetch_thumbnails():
resp = await client.get(
_proxy_url("https://thumbnails.roblox.com/v1/assets"), _proxy_url("https://thumbnails.roblox.com/v1/assets"),
params={"assetIds": ",".join(str(i) for i in asset_ids), "returnPolicy": "PlaceHolder", "size": "150x150", "format": "Png", "isCircular": "false"}, params={"assetIds": ",".join(str(i) for i in asset_ids), "returnPolicy": "PlaceHolder", "size": "150x150", "format": "Png", "isCircular": "false"},
) )
if thumb_resp.status_code == 200: resp.raise_for_status()
for t in thumb_resp.json().get("data", []): thumbs = {}
thumbnails[t["targetId"]] = t.get("imageUrl", "") for t in resp.json().get("data", []):
thumbs[t["targetId"]] = t.get("imageUrl", "")
return thumbs
details_by_id, thumbnails = await asyncio.gather(
_retry(_fetch_details), _retry(_fetch_thumbnails),
)
items = [] items = []
for item in raw_items: asset_type_id = 11 if asset_type == "Shirt" else 12
asset_id = item.get("id") for asset_id in asset_ids:
detail = details_by_id.get(asset_id, {})
price = detail.get("price")
lowest_price = detail.get("lowestPrice")
items.append({ items.append({
"id": asset_id, "id": asset_id,
"group_id": group_id, "group_id": group_id,
"collectible_item_id": item.get("collectibleItemId", ""), "collectible_item_id": detail.get("collectibleItemId", ""),
"name": item.get("name", ""), "name": detail.get("name", ""),
"asset_type": item.get("assetType"), "asset_type": detail.get("assetType", asset_type_id),
"price": item.get("price"), "price": price,
"lowest_price": item.get("lowestPrice"), "lowest_price": lowest_price,
"off_sale": item.get("priceStatus") == "Off Sale" or (item.get("price") is None and item.get("lowestPrice") is None), "off_sale": price is None and lowest_price is None,
"thumbnail": thumbnails.get(asset_id, ""), "thumbnail": thumbnails.get(asset_id, ""),
"created_utc": item.get("itemCreatedUtc", ""), "created_utc": detail.get("itemCreatedUtc", ""),
}) })
return { # Build next cursor
"items": items, if next_inner_cursor:
"next_cursor": data.get("nextPageCursor") or "", next_cursor = _encode_cursor({"type": asset_type, "cursor": next_inner_cursor})
} elif asset_type == "Shirt":
next_cursor = _encode_cursor({"type": "Pants", "cursor": ""})
else:
next_cursor = ""
return {"items": items, "next_cursor": next_cursor}
_SALE_HEADERS = { _SALE_HEADERS = {
+7 -10
View File
@@ -38,7 +38,7 @@
<label>Sort <label>Sort
<select id="sort-by" onchange="applyFilters()"> <select id="sort-by" onchange="applyFilters()">
<option value="">Default</option> <option value="">Default</option>
<option value="newest">Newest First</option> <option value="newest" selected>Newest First</option>
<option value="oldest">Oldest First</option> <option value="oldest">Oldest First</option>
</select> </select>
</label> </label>
@@ -60,9 +60,6 @@
<tbody></tbody> <tbody></tbody>
</table> </table>
<div style="margin-top:0.75rem">
<button id="load-more-btn" style="display:none" onclick="loadMore()">Load More</button>
</div>
</div> </div>
<div id="progress-section" style="display:none"> <div id="progress-section" style="display:none">
@@ -111,6 +108,7 @@ function fetchPage() {
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
const tbody = document.querySelector('#clothing-table tbody'); const tbody = document.querySelector('#clothing-table tbody');
const frag = document.createDocumentFragment();
data.items.forEach(item => { data.items.forEach(item => {
allItems.push(item); allItems.push(item);
const tr = document.createElement('tr'); const tr = document.createElement('tr');
@@ -139,12 +137,15 @@ function fetchPage() {
<td class="${statusClass}">${statusLabel}</td> <td class="${statusClass}">${statusLabel}</td>
<td style="white-space:nowrap">${uploadedLabel}</td> <td style="white-space:nowrap">${uploadedLabel}</td>
`; `;
tbody.appendChild(tr); frag.appendChild(tr);
}); });
tbody.appendChild(frag);
nextCursor = data.next_cursor; nextCursor = data.next_cursor;
document.getElementById('load-more-btn').style.display = nextCursor ? '' : 'none';
applyFilters(); applyFilters();
if (nextCursor) {
fetchPage();
}
}); });
} }
@@ -171,10 +172,6 @@ function applyFilters() {
}); });
} }
function loadMore() {
fetchPage();
}
function toggleSelectAll(cb) { function toggleSelectAll(cb) {
document.querySelectorAll('.item-cb').forEach(c => { document.querySelectorAll('.item-cb').forEach(c => {
const tr = c.closest('tr'); const tr = c.closest('tr');