Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- # Writing the complete rewritten script to a downloadable file.
- from pathlib import Path
- #!/usr/bin/env python3
- import argparse
- import csv
- import html
- import json
- import time
- import random
- from pathlib import Path
- from typing import Dict, Any, List, Optional, Tuple
- import requests
- class RobloxClient:
- def __init__(self, rate_limit_seconds: float = 0.30, max_retries: int = 5, backoff_base: float = 0.6, timeout: int = 20, max_items_per_stream: int = 50000):
- self.rate_limit_seconds = rate_limit_seconds
- self.max_retries = max_retries
- self.backoff_base = backoff_base
- self.timeout = timeout
- self.max_items_per_stream = max_items_per_stream
- self.headers = {"Accept": "application/json, text/plain, */*", "User-Agent": "Mozilla/5.0 (roblox-profile-scraper; ICAC tool)"}
- self.asset_type_enums = ["Image","TShirt","Audio","Mesh","Lua","Hat","Place","Model","Shirt","Pants","Decal","Head","Face","Gear","Badge","Animation","Torso","RightArm","LeftArm","LeftLeg","RightLeg","Package","GamePass","Plugin","MeshPart","HairAccessory","FaceAccessory","NeckAccessory","ShoulderAccessory","FrontAccessory","BackAccessory","WaistAccessory","ClimbAnimation","DeathAnimation","FallAnimation","IdleAnimation","JumpAnimation","RunAnimation","SwimAnimation","WalkAnimation","PoseAnimation","EarAccessory","EyeAccessory","EmoteAnimation","Video","TShirtAccessory","ShirtAccessory","PantsAccessory","JacketAccessory","SweaterAccessory","ShortsAccessory","LeftShoeAccessory","RightShoeAccessory","DressSkirtAccessory","FontFamily","EyebrowAccessory","EyelashAccessory","MoodAnimation","DynamicHead"]
- self.session = requests.Session()
- self.session.headers.update(self.headers)
- self.slow_until = 0.0
- self.missed_inventory_categories: List[str] = []
- def _sleep_rate_limit(self):
- delay = self.rate_limit_seconds
- now = time.time()
- if now < self.slow_until:
- delay = max(delay, 0.9)
- time.sleep(delay)
- def _request(self, method: str, url: str, *, params=None, json_body=None) -> Optional[requests.Response]:
- for attempt in range(1, self.max_retries + 1):
- try:
- r = self.session.request(method, url, params=params, json=json_body, timeout=self.timeout)
- except requests.RequestException as e:
- sleep = (self.backoff_base ** attempt) + random.uniform(0, 0.4)
- print(f"[request-exception] {e.__class__.__name__} backoff {sleep:.2f}s on {url} (attempt {attempt}/{self.max_retries})")
- time.sleep(sleep)
- continue
- if r.status_code == 429:
- ra = r.headers.get("Retry-After")
- try:
- ra_sec = float(ra) if ra is not None else (self.backoff_base ** attempt)
- except:
- ra_sec = (self.backoff_base ** attempt)
- print(f"[429] retry-after {ra_sec:.2f}s on {url} (attempt {attempt}/{self.max_retries})")
- self.slow_until = max(self.slow_until, time.time() + ra_sec)
- time.sleep(ra_sec + random.uniform(0, 0.4))
- continue
- if r.status_code in (500, 502, 503, 504):
- sleep = (self.backoff_base ** attempt) + random.uniform(0, 0.4)
- print(f"[{r.status_code}] backoff {sleep:.2f}s on {url} (attempt {attempt}/{self.max_retries})")
- time.sleep(sleep)
- continue
- return r
- print(f"[give-up] {method} {url}")
- time.sleep(3.0)
- try:
- return self.session.request(method, url, params=params, json=json_body, timeout=self.timeout)
- except requests.RequestException:
- return None
- def _get_json(self, url: str, params: Dict[str, Any] = None) -> Tuple[Optional[Dict[str, Any]], int]:
- self._sleep_rate_limit()
- r = self._request("GET", url, params=params)
- if not r:
- return None, 0
- code = r.status_code
- try:
- return r.json(), code
- except Exception:
- return None, code
- def _post_json(self, url: str, body: Dict[str, Any]) -> Optional[Dict[str, Any]]:
- self._sleep_rate_limit()
- r = self._request("POST", url, json_body=body)
- if not r or r.status_code != 200:
- return None
- try:
- return r.json()
- except Exception:
- return None
- def _paginate_cursor(self, url: str, *, limit_param_name: str = "limit", extra_params: Dict[str, Any] = None, per_page: int = 100, cursor_field: str = "nextPageCursor", data_field: str = "data", max_items: Optional[int] = None) -> Tuple[List[Any], bool]:
- max_items = max_items or self.max_items_per_stream
- out: List[Any] = []
- cursor = None
- params = dict(extra_params or {})
- ok = True
- while True:
- if len(out) >= max_items:
- break
- params[limit_param_name] = per_page
- if cursor:
- params["cursor"] = cursor
- blob, code = self._get_json(url, params)
- if code != 200 or not isinstance(blob, dict):
- print(f"[stop] non-200 or invalid JSON on {url} (status {code})")
- ok = False
- break
- data = blob.get(data_field, [])
- if isinstance(data, list):
- out.extend(data)
- cursor = blob.get(cursor_field) or blob.get("nextPageToken")
- if not cursor:
- break
- print(f"[page] {url} total {len(out)} of <= {max_items}")
- return out[:max_items], ok
- def usernames_to_id(self, username: str) -> Optional[int]:
- payload = {"usernames": [username], "excludeBannedUsers": False}
- resp = self._post_json("https://users.roblox.com/v1/usernames/users", payload)
- if not resp:
- return None
- try:
- data = resp.get("data", [])
- if not data:
- return None
- return int(data[0]["id"])
- except Exception:
- return None
- def fetch_user_details(self, user_id: int) -> Dict[str, Any]:
- print("Fetching user details…")
- blob, code = self._get_json(f"https://users.roblox.com/v1/users/{user_id}")
- if code == 200 and isinstance(blob, dict):
- return {"userId": user_id, "username": blob.get("name"), "displayName": blob.get("displayName"), "bio": blob.get("description"), "isBanned": blob.get("isBanned"), "created": blob.get("created")}
- return {"userId": user_id}
- def fetch_friends(self, user_id: int, max_items: Optional[int] = None) -> List[Dict[str, Any]]:
- print("Fetching friends…")
- blob, code = self._get_json(f"https://friends.roblox.com/v1/users/{user_id}/friends")
- if code == 200 and isinstance(blob, dict):
- data = blob.get("data", [])
- if isinstance(data, list):
- print(f"[friends] {len(data)}")
- return data[: (max_items or self.max_items_per_stream)]
- print("[friends] 0")
- return []
- def fetch_followers(self, user_id: int, max_items: Optional[int] = None) -> List[Dict[str, Any]]:
- print("Fetching followers…")
- res, ok = self._paginate_cursor(f"https://friends.roblox.com/v1/users/{user_id}/followers", per_page=100, max_items=max_items)
- print(f"[followers] {len(res)}")
- return res
- def fetch_following(self, user_id: int, max_items: Optional[int] = None) -> List[Dict[str, Any]]:
- print("Fetching following…")
- res, ok = self._paginate_cursor(f"https://friends.roblox.com/v1/users/{user_id}/followings", per_page=100, max_items=max_items)
- print(f"[following] {len(res)}")
- return res
- def fetch_groups(self, user_id: int) -> List[Dict[str, Any]]:
- print("Fetching groups…")
- blob, code = self._get_json(f"https://groups.roblox.com/v1/users/{user_id}/groups/roles")
- if code == 200 and isinstance(blob, dict):
- data = blob.get("data", [])
- print(f"[groups] {len(data) if isinstance(data, list) else 0}")
- return data if isinstance(data, list) else []
- print("[groups] 0")
- return []
- def fetch_outfits(self, user_id: int, max_items: Optional[int] = None) -> List[Dict[str, Any]]:
- print("Fetching outfits…")
- res, ok = self._paginate_cursor(f"https://avatar.roblox.com/v1/users/{user_id}/outfits", limit_param_name="itemsPerPage", per_page=50, max_items=max_items)
- res = [o for o in res if o.get("isEditable") is True]
- print(f"[outfits] {len(res)} (editable only)")
- return res
- def fetch_outfit_details(self, outfit_id: int) -> Dict[str, Any]:
- blob, code = self._get_json(f"https://avatar.roblox.com/v1/outfits/{outfit_id}/details")
- if code == 200 and isinstance(blob, dict):
- return blob
- return {}
- def fetch_outfit_thumbnails(self, outfit_ids: List[int], size: str = "420x420") -> Dict[int, Optional[str]]:
- if not outfit_ids:
- return {}
- url = "https://thumbnails.roblox.com/v1/users/outfits"
- result: Dict[int, Optional[str]] = {}
- step = 100
- def batch(ids):
- params = {"userOutfitIds": ",".join(str(x) for x in ids), "size": size, "format": "Png", "isCircular": "false"}
- blob, code = self._get_json(url, params=params)
- if code != 200 or not isinstance(blob, dict):
- for oid in ids:
- result[oid] = result.get(oid, None)
- return
- for d in blob.get("data", []):
- oid = int(d.get("targetId"))
- result[oid] = d.get("imageUrl")
- for i in range(0, len(outfit_ids), step):
- batch(outfit_ids[i:i+step])
- missing = [oid for oid in outfit_ids if not result.get(oid)]
- for round_idx in range(2):
- if not missing:
- break
- print(f"[thumbs] retry round {round_idx+1} for {len(missing)}")
- time.sleep(1.5 + 0.3*round_idx)
- for i in range(0, len(missing), step):
- batch(missing[i:i+step])
- missing = [oid for oid in missing if not result.get(oid)]
- for oid in outfit_ids:
- result.setdefault(oid, None)
- return result
- def fetch_asset_thumbnails(self, asset_ids: List[int], size: str = "150x150") -> Dict[int, Dict[str, Optional[str]]]:
- if not asset_ids:
- return {}
- url = "https://thumbnails.roblox.com/v1/assets"
- result: Dict[int, Dict[str, Optional[str]]] = {}
- step = 100
- def batch(ids):
- params = {"assetIds": ",".join(str(x) for x in ids), "size": size, "format": "Png", "isCircular": "false"}
- blob, code = self._get_json(url, params=params)
- if code != 200 or not isinstance(blob, dict):
- for aid in ids:
- result[aid] = result.get(aid, {"url": None, "state": None})
- return
- for d in blob.get("data", []):
- aid = int(d.get("targetId"))
- result[aid] = {"url": d.get("imageUrl"), "state": d.get("state")}
- for i in range(0, len(asset_ids), step):
- batch(asset_ids[i:i+step])
- missing = [aid for aid in asset_ids if not result.get(aid) or not result.get(aid, {}).get("url")]
- for round_idx in range(2):
- if not missing:
- break
- print(f"[asset-thumbs] retry round {round_idx+1} for {len(missing)}")
- time.sleep(1.2 + 0.3*round_idx)
- for i in range(0, len(missing), step):
- batch(missing[i:i+step])
- missing = [aid for aid in missing if not result.get(aid) or not result.get(aid, {}).get("url")]
- for aid in asset_ids:
- result.setdefault(aid, {"url": None, "state": None})
- return result
- def fetch_inventory_by_type(self, user_id: int, asset_type: str, max_items: Optional[int] = None) -> Tuple[List[Dict[str, Any]], bool]:
- print(f"Fetching inventory type: {asset_type}…")
- res, ok = self._paginate_cursor(f"https://inventory.roblox.com/v2/users/{user_id}/inventory", extra_params={"assetTypes": asset_type}, per_page=100, max_items=max_items)
- print(f"[inventory {asset_type}] {len(res)}")
- return res, ok
- def fetch_inventory_all_types(self, user_id: int, max_items: Optional[int] = None) -> Dict[str, List[Dict[str, Any]]]:
- results: Dict[str, List[Dict[str, Any]]] = {}
- failed: List[str] = []
- for t in self.asset_type_enums:
- items, ok = self.fetch_inventory_by_type(user_id, t, max_items=max_items)
- results[t] = items or []
- if not ok:
- failed.append(t)
- if failed:
- orig_rate = self.rate_limit_seconds
- orig_retries = self.max_retries
- self.rate_limit_seconds = max(self.rate_limit_seconds, 0.9)
- self.max_retries = max(self.max_retries, 10)
- print(f"[inventory] retry-pass for {len(failed)} categories at slower rate")
- still_failed: List[str] = []
- for t in failed:
- items, ok = self.fetch_inventory_by_type(user_id, t, max_items=max_items)
- results[t] = items or results.get(t, [])
- if not ok:
- still_failed.append(t)
- self.rate_limit_seconds = orig_rate
- self.max_retries = orig_retries
- for t in still_failed:
- print(f"[inventory] final-miss: {t}")
- self.missed_inventory_categories = still_failed
- else:
- self.missed_inventory_categories = []
- return results
- def build_profile(client: RobloxClient, username: str, *, max_items: Optional[int] = None) -> Dict[str, Any]:
- user_id = client.usernames_to_id(username)
- if not user_id:
- raise SystemExit(f"Could not resolve username '{username}'")
- print(f"Resolved username {username} -> userId {user_id}")
- core = client.fetch_user_details(user_id)
- profile: Dict[str, Any] = {"userId": user_id, "username": core.get("username", username), "displayName": core.get("displayName"), "bio": core.get("bio"), "isBanned": core.get("isBanned"), "created": core.get("created"), "fetchedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "source": "Roblox public/web APIs"}
- profile["friends"] = client.fetch_friends(user_id, max_items=max_items)
- profile["followers"] = client.fetch_followers(user_id, max_items=max_items)
- profile["following"] = client.fetch_following(user_id, max_items=max_items)
- profile["groups"] = client.fetch_groups(user_id)
- profile["outfits"] = client.fetch_outfits(user_id, max_items=max_items)
- profile["favorites"] = {"games": [], "assets": []}
- profile["inventory"] = client.fetch_inventory_all_types(user_id, max_items=max_items)
- profile["inventoryMissedCategories"] = client.missed_inventory_categories
- return profile
- def ensure_dir(p: Path):
- p.mkdir(parents=True, exist_ok=True)
- def to_csv(path: Path, rows: List[Dict[str, Any]]):
- if not rows:
- return
- keys = set()
- for r in rows:
- if isinstance(r, dict):
- keys |= set(r.keys())
- if not keys:
- return
- fieldnames = sorted(keys)
- with path.open("w", encoding="utf-8", newline="") as f:
- w = csv.DictWriter(f, fieldnames=fieldnames)
- w.writeheader()
- for r in rows:
- if not isinstance(r, dict):
- continue
- clean = {}
- for k in fieldnames:
- v = r.get(k)
- clean[k] = v if isinstance(v, (str, int, float, type(None), bool)) else json.dumps(v, ensure_ascii=False)
- w.writerow(clean)
- def save_csv_exports(username: str, profile: Dict[str, Any], outdir: Path):
- ensure_dir(outdir)
- to_csv(outdir / "friends.csv", profile.get("friends", []))
- to_csv(outdir / "followers.csv", profile.get("followers", []))
- to_csv(outdir / "following.csv", profile.get("following", []))
- to_csv(outdir / "groups.csv", profile.get("groups", []))
- to_csv(outdir / "outfits.csv", profile.get("outfits", []))
- fav = profile.get("favorites", {})
- to_csv(outdir / "favorites_games.csv", fav.get("games", []))
- inv = profile.get("inventory", {})
- for asset_type, items in inv.items():
- safe_name = asset_type.replace("/", "_")
- to_csv(outdir / f"inventory_{safe_name}.csv", items)
- def h(s: Any) -> str:
- return html.escape("" if s is None else str(s))
- def table(headers: List[str], rows: List[str]) -> str:
- thead = "<thead><tr>" + "".join(f"<th>{h(col)}</th>" for col in headers) + "</tr></thead>"
- tbody = "<tbody>" + "".join(rows) + "</tbody>"
- return f"<table class='compact'>{thead}{tbody}</table>"
- def html_section(title: str, headers: List[str], rows: List[str], count: int) -> str:
- return f"""
- <details open>
- <summary><strong>{h(title)}</strong> — {count} item(s)</summary>
- {table(headers, rows)}
- </details>
- """
- def to_html_report(username: str, profile: Dict[str, Any], outfit_thumb_paths: Dict[int, str], outfit_details: Dict[int, Dict[str, Any]], asset_thumb_paths: Dict[int, str], asset_thumb_meta: Dict[int, Dict[str, Optional[str]]]) -> str:
- def rows_for_friends(arr: List[Dict[str, Any]]) -> List[str]:
- rows = []
- for u in arr or []:
- uid = u.get("id") or u.get("userId") or u.get("uid")
- name = u.get("name") or u.get("username") or u.get("displayName") or ""
- link = f"https://www.roblox.com/users/{uid}/profile" if uid else ""
- rows.append(f"<tr><td>{h(name)}</td><td>{h(uid)}</td><td><a href='{h(link)}' target='_blank'>profile</a></td></tr>")
- return rows
- def rows_for_simple_users(arr: List[Dict[str, Any]]) -> List[str]:
- rows = []
- for u in arr or []:
- uid = u.get("id") or u.get("userId") or u.get("uid")
- link = f"https://www.roblox.com/users/{uid}/profile" if uid else ""
- rows.append(f"<tr><td>{h(uid)}</td><td><a href='{h(link)}' target='_blank'>profile</a></td></tr>")
- return rows
- def groups_cards(arr: List[Dict[str, Any]]) -> str:
- parts: List[str] = []
- for g in arr or []:
- grp = g.get("group") or {}
- role = g.get("role") or {}
- gid = grp.get("id")
- gname = grp.get("name") or ""
- link = f"https://www.roblox.com/groups/{gid}" if gid else ""
- rname = role.get("name")
- rank = role.get("rank")
- mc = grp.get("memberCount")
- desc = grp.get("description") or ""
- owner = grp.get("owner") or {}
- o_uname = owner.get("username") or owner.get("displayName")
- o_id = owner.get("userId")
- o_v = owner.get("hasVerifiedBadge")
- g_v = grp.get("hasVerifiedBadge")
- public_entry = grp.get("publicEntryAllowed")
- bc_only = grp.get("isBuildersClubOnly")
- shout = grp.get("shout")
- shout_text = ""
- if isinstance(shout, dict):
- shout_text = shout.get("body") or shout.get("message") or ""
- parts.append(
- f"""
- <details class="group-card">
- <summary>
- <strong>{h(gname)}</strong>
- <span class="pill">{h(rname)}</span>
- <span class="pill">Rank {h(rank)}</span>
- <span class="pill">GroupId {h(gid)}</span>
- <span class="pill">Members {h(mc)}</span>
- <a class="pill link" href="{h(link)}" target="_blank">Open</a>
- </summary>
- <table class="kv">
- <tr><th>Description</th><td class="prewrap">{h(desc)}</td></tr>
- <tr><th>Owner</th><td>{h(o_uname)} ({h(o_id)}){' ✓' if o_v else ''}</td></tr>
- <tr><th>Verified</th><td>{'✓' if g_v else '✗'}</td></tr>
- <tr><th>Public Entry</th><td>{'✓' if public_entry else '✗'}</td></tr>
- <tr><th>Builders Club Only</th><td>{'✓' if bc_only else '✗'}</td></tr>
- {f'<tr><th>Shout</th><td class="prewrap">{h(shout_text)}</td></tr>' if shout_text else ''}
- </table>
- </details>
- """
- )
- return "\n".join(parts) if parts else "<p class='small'>No groups found.</p>"
- def outfit_cards(arr: List[Dict[str, Any]]) -> str:
- parts: List[str] = []
- for o in arr or []:
- oid = o.get("id")
- oname = o.get("name") or ""
- thumb = outfit_thumb_paths.get(int(oid)) if isinstance(oid, int) else None
- assets = []
- det = outfit_details.get(int(oid), {}) if isinstance(oid, int) else {}
- a_list = det.get("assets") or det.get("assetsList") or det.get("Assets") or []
- for a in a_list:
- aid = a.get("id") or a.get("assetId")
- aname = a.get("name") or a.get("assetName") or ""
- assets.append({"id": aid, "name": aname})
- rows = []
- for a in assets:
- aid = a.get("id")
- an = a.get("name")
- link = f"https://www.roblox.com/catalog/{aid}" if aid else ""
- ath = asset_thumb_paths.get(int(aid)) if isinstance(aid, int) else None
- meta = asset_thumb_meta.get(int(aid), {}) if isinstance(aid, int) else {}
- state = meta.get("state") or ("NoThumb" if not ath else "OK")
- img_html = f"<img src='{h(ath)}' alt='Asset {h(aid)}' style='height:75px'>" if ath else "<span class='small'>No thumbnail</span>"
- rows.append(f"<tr><td>{img_html}</td><td>{h(an)}</td><td>{h(aid)}</td><td>{h(state)}</td><td><a href='{h(link)}' target='_blank'>catalog</a></td></tr>")
- inner = table(["Thumb","Name","AssetId","Status","Link"], rows) if rows else "<p class='small'>No items in outfit.</p>"
- img_html = f"<img src='{h(thumb)}' alt='Outfit {h(oid)}' style='height:120px'>" if thumb else "<span class='small'>No outfit thumbnail</span>"
- parts.append(
- f"""
- <details class="outfit-card">
- <summary>
- <strong>{h(oname)}</strong>
- <span class="pill">OutfitId {h(oid)}</span>
- <span class="pill">Items {len(assets)}</span>
- <span class="pill thumb">{img_html}</span>
- </summary>
- {inner}
- </details>
- """
- )
- return "\n".join(parts) if parts else "<p class='small'>No outfits found.</p>"
- def inventory_sections(inv: Dict[str, List[Dict[str, Any]]]) -> str:
- if not inv:
- return "<p class='small'>No assets found.</p>"
- missed = set(profile.get("inventoryMissedCategories", []))
- parts: List[str] = []
- for t, items in inv.items():
- flag = t in missed
- if flag:
- inner = "<p class='small warn'>Data not collected due to rate limiting.</p>"
- elif not items:
- inner = "<p class='small'>No assets found.</p>"
- else:
- rows = []
- for it in items:
- aid = it.get("assetId") or it.get("id")
- aname = (it.get("name") or it.get("assetName") or "").strip()
- link = f"https://www.roblox.com/catalog/{aid}" if aid else ""
- rows.append(f"<tr><td>{h(aname)}</td><td>{h(aid)}</td><td><a href='{h(link)}' target='_blank'>catalog</a></td></tr>")
- inner = table(["Name","AssetId","Link"], rows)
- pill = " <span class='pill warn'>Rate limited</span>" if flag else ""
- parts.append(f"<details open><summary><strong>{h(t)}</strong> — {len(items)} item(s){pill}</summary>{inner}</details>")
- return "\n".join(parts)
- friends = profile.get("friends", [])
- followers = profile.get("followers", [])
- following = profile.get("following", [])
- groups = profile.get("groups", [])
- outfits = profile.get("outfits", [])
- inventory = profile.get("inventory", {})
- created = profile.get("created")
- is_banned = profile.get("isBanned")
- display = profile.get("displayName")
- uname = profile.get("username")
- uid = profile.get("userId")
- pf_link = f"https://www.roblox.com/users/{uid}/profile"
- html_doc = f"""<!doctype html>
- <html>
- <head>
- <meta charset="utf-8">
- <title>Roblox Profile Report — {h(uname)}</title>
- <style>
- body {{
- font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
- margin: 24px;
- }}
- header {{ margin-bottom: 16px; }}
- summary {{ cursor: pointer; padding: 6px 0; }}
- table.compact {{
- border-collapse: collapse;
- width: auto;
- max-width: 100%;
- margin: 6px 0 16px;
- display: inline-table;
- }}
- td, th {{
- border: 1px solid #ddd;
- padding: 6px 8px;
- font-size: 14px;
- vertical-align: top;
- }}
- th {{
- background: #f5f5f5;
- text-align: left;
- }}
- tbody tr:nth-child(even) td {{
- background: #fafafa;
- }}
- tr.subhead th {{
- background: #f0f0f0;
- font-weight: 600;
- }}
- tr.empty td {{
- color: #666;
- font-style: italic;
- }}
- .meta span {{ display: inline-block; margin-right: 12px; }}
- .badge {{
- display: inline-block; padding: 2px 8px;
- border-radius: 10px; font-size: 12px; background:#eee; margin-left:8px;
- }}
- .small {{ color:#666; font-size:12px; }}
- .small.warn {{ color:#a13; }}
- a {{ text-decoration: none; }}
- a:hover {{ text-decoration: underline; }}
- img {{ display:block; }}
- .group-card, .outfit-card {{
- border: 1px solid #ddd; border-radius: 8px; padding: 8px 12px; margin: 8px 0;
- }}
- .group-card > summary, .outfit-card > summary {{
- display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
- }}
- .pill {{
- background: #f5f5f5; border: 1px solid #e2e2e2; border-radius: 999px; padding: 2px 8px; font-size: 12px;
- }}
- .pill.link {{ background: #eef; border-color: #dde; }}
- .pill.thumb {{ background: transparent; border: 0; padding: 0; }}
- .pill.warn {{ background: #ffe9d6; border-color: #f6c08f; color: #8a3b00; }}
- .kv {{
- border-collapse: collapse; width: auto; margin-top: 8px;
- }}
- .kv th, .kv td {{
- border: 1px solid #ddd; padding: 6px 8px; font-size: 14px; vertical-align: top;
- }}
- .kv th {{
- background: #fafafa; white-space: nowrap;
- }}
- .prewrap {{
- white-space: pre-wrap; word-break: break-word; max-width: 72ch;
- }}
- </style>
- </head>
- <body>
- <header>
- <h1>Roblox Profile Report — {h(uname)} <span class="badge">{h(display)}</span></h1>
- <div class="meta">
- <span><strong>UserId:</strong> {h(uid)}</span>
- <span><strong>Created:</strong> {h(created)}</span>
- <span><strong>isBanned:</strong> {h(is_banned)}</span>
- <span><a href="{h(pf_link)}" target="_blank">Open profile</a></span>
- </div>
- <p class="small">Generated at {h(profile.get('fetchedAt'))} — Source: Roblox public APIs</p>
- <p><strong>Bio:</strong> {h(profile.get('bio'))}</p>
- </header>
- {html_section("Friends", ["Name", "UserId", "Link"], rows_for_friends(friends), len(friends))}
- {html_section("Followers", ["UserId", "Link"], rows_for_simple_users(followers), len(followers))}
- {html_section("Following", ["UserId", "Link"], rows_for_simple_users(following), len(following))}
- <details open>
- <summary><strong>Groups</strong> — {len(groups)} item(s)</summary>
- {groups_cards(groups)}
- </details>
- <details open>
- <summary><strong>Outfits</strong> — {len(outfits)} item(s)</summary>
- {outfit_cards(outfits)}
- </details>
- <details open>
- <summary><strong>Inventory (by AssetType)</strong> — {sum(len(v) for v in inventory.values())} item(s)</summary>
- {inventory_sections(inventory)}
- </details>
- </body></html>
- """
- return html_doc
- def parse_args() -> argparse.Namespace:
- p = argparse.ArgumentParser(description="Roblox profile scraper → JSON + HTML + CSV (public APIs)")
- p.add_argument("--max-items", type=int, default=50000)
- p.add_argument("--rate", type=float, default=0.30)
- p.add_argument("--retries", type=int, default=5)
- p.add_argument("--timeout", type=int, default=20)
- p.add_argument("--outdir", default=".")
- return p.parse_args()
- def download_images(urls: Dict[int, Optional[str]], prefix: str, outdir: Path, headers: Dict[str,str]) -> Dict[int, str]:
- ensure_dir(outdir)
- saved: Dict[int, str] = {}
- for k, url in urls.items():
- if not url:
- continue
- img_path = outdir / f"{prefix}_{k}.png"
- try:
- r = requests.get(url, headers=headers, timeout=30)
- if r.status_code == 200 and r.content:
- with img_path.open("wb") as f:
- f.write(r.content)
- saved[k] = str(img_path)
- else:
- print(f"[{prefix}] failed {k} status {r.status_code}")
- except requests.RequestException as e:
- print(f"[{prefix}] error {k} {e.__class__.__name__}")
- print(f"[{prefix}] saved {len(saved)}")
- return saved
- def main():
- args = parse_args()
- username = input("Enter the Roblox username to scrape: ").strip()
- base_outdir = Path(args.outdir).resolve()
- base_outdir.mkdir(parents=True, exist_ok=True)
- client = RobloxClient(rate_limit_seconds=args.rate, max_retries=args.retries, timeout=args.timeout, max_items_per_stream=max(1, int(args.max_items)))
- profile = build_profile(client, username, max_items=client.max_items_per_stream)
- json_path = base_outdir / f"{profile['username']}_profile.json"
- with json_path.open("w", encoding="utf-8") as f:
- json.dump(profile, f, ensure_ascii=False, indent=2)
- print(f"Wrote {json_path}")
- csv_dir = base_outdir / f"{profile['username']}_exports"
- ensure_dir(csv_dir)
- save_csv_exports(profile['username'], profile, csv_dir)
- print(f"Wrote CSV exports to {csv_dir}")
- outfits = profile.get("outfits", [])
- outfit_ids = [o.get("id") for o in outfits if isinstance(o.get("id"), int)]
- img_dir = csv_dir / "images"
- outfit_thumbs = client.fetch_outfit_thumbnails(outfit_ids)
- local_outfit_thumbs = download_images(outfit_thumbs, "outfit", img_dir, client.headers)
- outfit_details: Dict[int, Dict[str, Any]] = {}
- all_asset_ids: List[int] = []
- for idx, oid in enumerate(outfit_ids, 1):
- det = client.fetch_outfit_details(int(oid))
- outfit_details[int(oid)] = det or {}
- assets = det.get("assets") or det.get("Assets") or []
- for a in assets:
- aid = a.get("id") or a.get("assetId")
- if isinstance(aid, int):
- all_asset_ids.append(aid)
- print(f"[outfit-details] {idx}/{len(outfit_ids)} id={oid} assets={len(assets) if isinstance(assets, list) else 0}")
- all_asset_ids = sorted({a for a in all_asset_ids if isinstance(a, int)})
- print(f"[asset-thumbs] requesting {len(all_asset_ids)}")
- asset_thumb_meta = client.fetch_asset_thumbnails(all_asset_ids, size="150x150")
- asset_thumb_urls = {aid: meta.get("url") for aid, meta in asset_thumb_meta.items()}
- local_asset_thumbs = download_images(asset_thumb_urls, "asset", img_dir, client.headers)
- html_path = base_outdir / f"{profile['username']}_report.html"
- rel_outfit_thumbs: Dict[int, str] = {k: str(Path(v).relative_to(base_outdir)) for k, v in local_outfit_thumbs.items()}
- rel_asset_thumbs: Dict[int, str] = {k: str(Path(v).relative_to(base_outdir)) for k, v in local_asset_thumbs.items()}
- html_report = to_html_report(profile['username'], profile, rel_outfit_thumbs, outfit_details, rel_asset_thumbs, asset_thumb_meta)
- with html_path.open("w", encoding="utf-8") as f:
- f.write(html_report)
- print(f"Wrote {html_path}")
- if __name__ == "__main__":
- main()
Advertisement
Add Comment
Please, Sign In to add comment