Guest User

rbxscraper(beta)

a guest
Sep 12th, 2025
214
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 31.93 KB | None | 0 0
  1. # Writing the complete rewritten script to a downloadable file.
  2. from pathlib import Path
  3.  
  4. #!/usr/bin/env python3
  5. import argparse
  6. import csv
  7. import html
  8. import json
  9. import time
  10. import random
  11. from pathlib import Path
  12. from typing import Dict, Any, List, Optional, Tuple
  13. import requests
  14.  
  15. class RobloxClient:
  16.     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):
  17.         self.rate_limit_seconds = rate_limit_seconds
  18.         self.max_retries = max_retries
  19.         self.backoff_base = backoff_base
  20.         self.timeout = timeout
  21.         self.max_items_per_stream = max_items_per_stream
  22.         self.headers = {"Accept": "application/json, text/plain, */*", "User-Agent": "Mozilla/5.0 (roblox-profile-scraper; ICAC tool)"}
  23.         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"]
  24.         self.session = requests.Session()
  25.         self.session.headers.update(self.headers)
  26.         self.slow_until = 0.0
  27.         self.missed_inventory_categories: List[str] = []
  28.  
  29.     def _sleep_rate_limit(self):
  30.         delay = self.rate_limit_seconds
  31.         now = time.time()
  32.         if now < self.slow_until:
  33.             delay = max(delay, 0.9)
  34.         time.sleep(delay)
  35.  
  36.     def _request(self, method: str, url: str, *, params=None, json_body=None) -> Optional[requests.Response]:
  37.         for attempt in range(1, self.max_retries + 1):
  38.             try:
  39.                 r = self.session.request(method, url, params=params, json=json_body, timeout=self.timeout)
  40.             except requests.RequestException as e:
  41.                 sleep = (self.backoff_base ** attempt) + random.uniform(0, 0.4)
  42.                 print(f"[request-exception] {e.__class__.__name__} backoff {sleep:.2f}s on {url} (attempt {attempt}/{self.max_retries})")
  43.                 time.sleep(sleep)
  44.                 continue
  45.             if r.status_code == 429:
  46.                 ra = r.headers.get("Retry-After")
  47.                 try:
  48.                     ra_sec = float(ra) if ra is not None else (self.backoff_base ** attempt)
  49.                 except:
  50.                     ra_sec = (self.backoff_base ** attempt)
  51.                 print(f"[429] retry-after {ra_sec:.2f}s on {url} (attempt {attempt}/{self.max_retries})")
  52.                 self.slow_until = max(self.slow_until, time.time() + ra_sec)
  53.                 time.sleep(ra_sec + random.uniform(0, 0.4))
  54.                 continue
  55.             if r.status_code in (500, 502, 503, 504):
  56.                 sleep = (self.backoff_base ** attempt) + random.uniform(0, 0.4)
  57.                 print(f"[{r.status_code}] backoff {sleep:.2f}s on {url} (attempt {attempt}/{self.max_retries})")
  58.                 time.sleep(sleep)
  59.                 continue
  60.             return r
  61.         print(f"[give-up] {method} {url}")
  62.         time.sleep(3.0)
  63.         try:
  64.             return self.session.request(method, url, params=params, json=json_body, timeout=self.timeout)
  65.         except requests.RequestException:
  66.             return None
  67.  
  68.     def _get_json(self, url: str, params: Dict[str, Any] = None) -> Tuple[Optional[Dict[str, Any]], int]:
  69.         self._sleep_rate_limit()
  70.         r = self._request("GET", url, params=params)
  71.         if not r:
  72.             return None, 0
  73.         code = r.status_code
  74.         try:
  75.             return r.json(), code
  76.         except Exception:
  77.             return None, code
  78.  
  79.     def _post_json(self, url: str, body: Dict[str, Any]) -> Optional[Dict[str, Any]]:
  80.         self._sleep_rate_limit()
  81.         r = self._request("POST", url, json_body=body)
  82.         if not r or r.status_code != 200:
  83.             return None
  84.         try:
  85.             return r.json()
  86.         except Exception:
  87.             return None
  88.  
  89.     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]:
  90.         max_items = max_items or self.max_items_per_stream
  91.         out: List[Any] = []
  92.         cursor = None
  93.         params = dict(extra_params or {})
  94.         ok = True
  95.         while True:
  96.             if len(out) >= max_items:
  97.                 break
  98.             params[limit_param_name] = per_page
  99.             if cursor:
  100.                 params["cursor"] = cursor
  101.             blob, code = self._get_json(url, params)
  102.             if code != 200 or not isinstance(blob, dict):
  103.                 print(f"[stop] non-200 or invalid JSON on {url} (status {code})")
  104.                 ok = False
  105.                 break
  106.             data = blob.get(data_field, [])
  107.             if isinstance(data, list):
  108.                 out.extend(data)
  109.             cursor = blob.get(cursor_field) or blob.get("nextPageToken")
  110.             if not cursor:
  111.                 break
  112.             print(f"[page] {url} total {len(out)} of <= {max_items}")
  113.         return out[:max_items], ok
  114.  
  115.     def usernames_to_id(self, username: str) -> Optional[int]:
  116.         payload = {"usernames": [username], "excludeBannedUsers": False}
  117.         resp = self._post_json("https://users.roblox.com/v1/usernames/users", payload)
  118.         if not resp:
  119.             return None
  120.         try:
  121.             data = resp.get("data", [])
  122.             if not data:
  123.                 return None
  124.             return int(data[0]["id"])
  125.         except Exception:
  126.             return None
  127.  
  128.     def fetch_user_details(self, user_id: int) -> Dict[str, Any]:
  129.         print("Fetching user details…")
  130.         blob, code = self._get_json(f"https://users.roblox.com/v1/users/{user_id}")
  131.         if code == 200 and isinstance(blob, dict):
  132.             return {"userId": user_id, "username": blob.get("name"), "displayName": blob.get("displayName"), "bio": blob.get("description"), "isBanned": blob.get("isBanned"), "created": blob.get("created")}
  133.         return {"userId": user_id}
  134.  
  135.     def fetch_friends(self, user_id: int, max_items: Optional[int] = None) -> List[Dict[str, Any]]:
  136.         print("Fetching friends…")
  137.         blob, code = self._get_json(f"https://friends.roblox.com/v1/users/{user_id}/friends")
  138.         if code == 200 and isinstance(blob, dict):
  139.             data = blob.get("data", [])
  140.             if isinstance(data, list):
  141.                 print(f"[friends] {len(data)}")
  142.                 return data[: (max_items or self.max_items_per_stream)]
  143.         print("[friends] 0")
  144.         return []
  145.  
  146.     def fetch_followers(self, user_id: int, max_items: Optional[int] = None) -> List[Dict[str, Any]]:
  147.         print("Fetching followers…")
  148.         res, ok = self._paginate_cursor(f"https://friends.roblox.com/v1/users/{user_id}/followers", per_page=100, max_items=max_items)
  149.         print(f"[followers] {len(res)}")
  150.         return res
  151.  
  152.     def fetch_following(self, user_id: int, max_items: Optional[int] = None) -> List[Dict[str, Any]]:
  153.         print("Fetching following…")
  154.         res, ok = self._paginate_cursor(f"https://friends.roblox.com/v1/users/{user_id}/followings", per_page=100, max_items=max_items)
  155.         print(f"[following] {len(res)}")
  156.         return res
  157.  
  158.     def fetch_groups(self, user_id: int) -> List[Dict[str, Any]]:
  159.         print("Fetching groups…")
  160.         blob, code = self._get_json(f"https://groups.roblox.com/v1/users/{user_id}/groups/roles")
  161.         if code == 200 and isinstance(blob, dict):
  162.             data = blob.get("data", [])
  163.             print(f"[groups] {len(data) if isinstance(data, list) else 0}")
  164.             return data if isinstance(data, list) else []
  165.         print("[groups] 0")
  166.         return []
  167.  
  168.     def fetch_outfits(self, user_id: int, max_items: Optional[int] = None) -> List[Dict[str, Any]]:
  169.         print("Fetching outfits…")
  170.         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)
  171.         res = [o for o in res if o.get("isEditable") is True]
  172.         print(f"[outfits] {len(res)} (editable only)")
  173.         return res
  174.  
  175.     def fetch_outfit_details(self, outfit_id: int) -> Dict[str, Any]:
  176.         blob, code = self._get_json(f"https://avatar.roblox.com/v1/outfits/{outfit_id}/details")
  177.         if code == 200 and isinstance(blob, dict):
  178.             return blob
  179.         return {}
  180.  
  181.     def fetch_outfit_thumbnails(self, outfit_ids: List[int], size: str = "420x420") -> Dict[int, Optional[str]]:
  182.         if not outfit_ids:
  183.             return {}
  184.         url = "https://thumbnails.roblox.com/v1/users/outfits"
  185.         result: Dict[int, Optional[str]] = {}
  186.         step = 100
  187.         def batch(ids):
  188.             params = {"userOutfitIds": ",".join(str(x) for x in ids), "size": size, "format": "Png", "isCircular": "false"}
  189.             blob, code = self._get_json(url, params=params)
  190.             if code != 200 or not isinstance(blob, dict):
  191.                 for oid in ids:
  192.                     result[oid] = result.get(oid, None)
  193.                 return
  194.             for d in blob.get("data", []):
  195.                 oid = int(d.get("targetId"))
  196.                 result[oid] = d.get("imageUrl")
  197.         for i in range(0, len(outfit_ids), step):
  198.             batch(outfit_ids[i:i+step])
  199.         missing = [oid for oid in outfit_ids if not result.get(oid)]
  200.         for round_idx in range(2):
  201.             if not missing:
  202.                 break
  203.             print(f"[thumbs] retry round {round_idx+1} for {len(missing)}")
  204.             time.sleep(1.5 + 0.3*round_idx)
  205.             for i in range(0, len(missing), step):
  206.                 batch(missing[i:i+step])
  207.             missing = [oid for oid in missing if not result.get(oid)]
  208.         for oid in outfit_ids:
  209.             result.setdefault(oid, None)
  210.         return result
  211.  
  212.     def fetch_asset_thumbnails(self, asset_ids: List[int], size: str = "150x150") -> Dict[int, Dict[str, Optional[str]]]:
  213.         if not asset_ids:
  214.             return {}
  215.         url = "https://thumbnails.roblox.com/v1/assets"
  216.         result: Dict[int, Dict[str, Optional[str]]] = {}
  217.         step = 100
  218.         def batch(ids):
  219.             params = {"assetIds": ",".join(str(x) for x in ids), "size": size, "format": "Png", "isCircular": "false"}
  220.             blob, code = self._get_json(url, params=params)
  221.             if code != 200 or not isinstance(blob, dict):
  222.                 for aid in ids:
  223.                     result[aid] = result.get(aid, {"url": None, "state": None})
  224.                 return
  225.             for d in blob.get("data", []):
  226.                 aid = int(d.get("targetId"))
  227.                 result[aid] = {"url": d.get("imageUrl"), "state": d.get("state")}
  228.         for i in range(0, len(asset_ids), step):
  229.             batch(asset_ids[i:i+step])
  230.         missing = [aid for aid in asset_ids if not result.get(aid) or not result.get(aid, {}).get("url")]
  231.         for round_idx in range(2):
  232.             if not missing:
  233.                 break
  234.             print(f"[asset-thumbs] retry round {round_idx+1} for {len(missing)}")
  235.             time.sleep(1.2 + 0.3*round_idx)
  236.             for i in range(0, len(missing), step):
  237.                 batch(missing[i:i+step])
  238.             missing = [aid for aid in missing if not result.get(aid) or not result.get(aid, {}).get("url")]
  239.         for aid in asset_ids:
  240.             result.setdefault(aid, {"url": None, "state": None})
  241.         return result
  242.  
  243.     def fetch_inventory_by_type(self, user_id: int, asset_type: str, max_items: Optional[int] = None) -> Tuple[List[Dict[str, Any]], bool]:
  244.         print(f"Fetching inventory type: {asset_type}…")
  245.         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)
  246.         print(f"[inventory {asset_type}] {len(res)}")
  247.         return res, ok
  248.  
  249.     def fetch_inventory_all_types(self, user_id: int, max_items: Optional[int] = None) -> Dict[str, List[Dict[str, Any]]]:
  250.         results: Dict[str, List[Dict[str, Any]]] = {}
  251.         failed: List[str] = []
  252.         for t in self.asset_type_enums:
  253.             items, ok = self.fetch_inventory_by_type(user_id, t, max_items=max_items)
  254.             results[t] = items or []
  255.             if not ok:
  256.                 failed.append(t)
  257.         if failed:
  258.             orig_rate = self.rate_limit_seconds
  259.             orig_retries = self.max_retries
  260.             self.rate_limit_seconds = max(self.rate_limit_seconds, 0.9)
  261.             self.max_retries = max(self.max_retries, 10)
  262.             print(f"[inventory] retry-pass for {len(failed)} categories at slower rate")
  263.             still_failed: List[str] = []
  264.             for t in failed:
  265.                 items, ok = self.fetch_inventory_by_type(user_id, t, max_items=max_items)
  266.                 results[t] = items or results.get(t, [])
  267.                 if not ok:
  268.                     still_failed.append(t)
  269.             self.rate_limit_seconds = orig_rate
  270.             self.max_retries = orig_retries
  271.             for t in still_failed:
  272.                 print(f"[inventory] final-miss: {t}")
  273.             self.missed_inventory_categories = still_failed
  274.         else:
  275.             self.missed_inventory_categories = []
  276.         return results
  277.  
  278. def build_profile(client: RobloxClient, username: str, *, max_items: Optional[int] = None) -> Dict[str, Any]:
  279.     user_id = client.usernames_to_id(username)
  280.     if not user_id:
  281.         raise SystemExit(f"Could not resolve username '{username}'")
  282.     print(f"Resolved username {username} -> userId {user_id}")
  283.     core = client.fetch_user_details(user_id)
  284.     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"}
  285.     profile["friends"] = client.fetch_friends(user_id, max_items=max_items)
  286.     profile["followers"] = client.fetch_followers(user_id, max_items=max_items)
  287.     profile["following"] = client.fetch_following(user_id, max_items=max_items)
  288.     profile["groups"] = client.fetch_groups(user_id)
  289.     profile["outfits"] = client.fetch_outfits(user_id, max_items=max_items)
  290.     profile["favorites"] = {"games": [], "assets": []}
  291.     profile["inventory"] = client.fetch_inventory_all_types(user_id, max_items=max_items)
  292.     profile["inventoryMissedCategories"] = client.missed_inventory_categories
  293.     return profile
  294.  
  295. def ensure_dir(p: Path):
  296.     p.mkdir(parents=True, exist_ok=True)
  297.  
  298. def to_csv(path: Path, rows: List[Dict[str, Any]]):
  299.     if not rows:
  300.         return
  301.     keys = set()
  302.     for r in rows:
  303.         if isinstance(r, dict):
  304.             keys |= set(r.keys())
  305.     if not keys:
  306.         return
  307.     fieldnames = sorted(keys)
  308.     with path.open("w", encoding="utf-8", newline="") as f:
  309.         w = csv.DictWriter(f, fieldnames=fieldnames)
  310.         w.writeheader()
  311.         for r in rows:
  312.             if not isinstance(r, dict):
  313.                 continue
  314.             clean = {}
  315.             for k in fieldnames:
  316.                 v = r.get(k)
  317.                 clean[k] = v if isinstance(v, (str, int, float, type(None), bool)) else json.dumps(v, ensure_ascii=False)
  318.             w.writerow(clean)
  319.  
  320. def save_csv_exports(username: str, profile: Dict[str, Any], outdir: Path):
  321.     ensure_dir(outdir)
  322.     to_csv(outdir / "friends.csv", profile.get("friends", []))
  323.     to_csv(outdir / "followers.csv", profile.get("followers", []))
  324.     to_csv(outdir / "following.csv", profile.get("following", []))
  325.     to_csv(outdir / "groups.csv", profile.get("groups", []))
  326.     to_csv(outdir / "outfits.csv", profile.get("outfits", []))
  327.     fav = profile.get("favorites", {})
  328.     to_csv(outdir / "favorites_games.csv", fav.get("games", []))
  329.     inv = profile.get("inventory", {})
  330.     for asset_type, items in inv.items():
  331.         safe_name = asset_type.replace("/", "_")
  332.         to_csv(outdir / f"inventory_{safe_name}.csv", items)
  333.  
  334. def h(s: Any) -> str:
  335.     return html.escape("" if s is None else str(s))
  336.  
  337. def table(headers: List[str], rows: List[str]) -> str:
  338.     thead = "<thead><tr>" + "".join(f"<th>{h(col)}</th>" for col in headers) + "</tr></thead>"
  339.     tbody = "<tbody>" + "".join(rows) + "</tbody>"
  340.     return f"<table class='compact'>{thead}{tbody}</table>"
  341.  
  342. def html_section(title: str, headers: List[str], rows: List[str], count: int) -> str:
  343.     return f"""
  344.    <details open>
  345.      <summary><strong>{h(title)}</strong> — {count} item(s)</summary>
  346.      {table(headers, rows)}
  347.    </details>
  348.    """
  349.  
  350. 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:
  351.     def rows_for_friends(arr: List[Dict[str, Any]]) -> List[str]:
  352.         rows = []
  353.         for u in arr or []:
  354.             uid = u.get("id") or u.get("userId") or u.get("uid")
  355.             name = u.get("name") or u.get("username") or u.get("displayName") or ""
  356.             link = f"https://www.roblox.com/users/{uid}/profile" if uid else ""
  357.             rows.append(f"<tr><td>{h(name)}</td><td>{h(uid)}</td><td><a href='{h(link)}' target='_blank'>profile</a></td></tr>")
  358.         return rows
  359.  
  360.     def rows_for_simple_users(arr: List[Dict[str, Any]]) -> List[str]:
  361.         rows = []
  362.         for u in arr or []:
  363.             uid = u.get("id") or u.get("userId") or u.get("uid")
  364.             link = f"https://www.roblox.com/users/{uid}/profile" if uid else ""
  365.             rows.append(f"<tr><td>{h(uid)}</td><td><a href='{h(link)}' target='_blank'>profile</a></td></tr>")
  366.         return rows
  367.  
  368.     def groups_cards(arr: List[Dict[str, Any]]) -> str:
  369.         parts: List[str] = []
  370.         for g in arr or []:
  371.             grp = g.get("group") or {}
  372.             role = g.get("role") or {}
  373.             gid = grp.get("id")
  374.             gname = grp.get("name") or ""
  375.             link = f"https://www.roblox.com/groups/{gid}" if gid else ""
  376.             rname = role.get("name")
  377.             rank = role.get("rank")
  378.             mc = grp.get("memberCount")
  379.             desc = grp.get("description") or ""
  380.             owner = grp.get("owner") or {}
  381.             o_uname = owner.get("username") or owner.get("displayName")
  382.             o_id = owner.get("userId")
  383.             o_v = owner.get("hasVerifiedBadge")
  384.             g_v = grp.get("hasVerifiedBadge")
  385.             public_entry = grp.get("publicEntryAllowed")
  386.             bc_only = grp.get("isBuildersClubOnly")
  387.             shout = grp.get("shout")
  388.             shout_text = ""
  389.             if isinstance(shout, dict):
  390.                 shout_text = shout.get("body") or shout.get("message") or ""
  391.             parts.append(
  392.                 f"""
  393.                <details class="group-card">
  394.                  <summary>
  395.                    <strong>{h(gname)}</strong>
  396.                    <span class="pill">{h(rname)}</span>
  397.                    <span class="pill">Rank {h(rank)}</span>
  398.                    <span class="pill">GroupId {h(gid)}</span>
  399.                    <span class="pill">Members {h(mc)}</span>
  400.                    <a class="pill link" href="{h(link)}" target="_blank">Open</a>
  401.                  </summary>
  402.                  <table class="kv">
  403.                    <tr><th>Description</th><td class="prewrap">{h(desc)}</td></tr>
  404.                    <tr><th>Owner</th><td>{h(o_uname)} ({h(o_id)}){' ✓' if o_v else ''}</td></tr>
  405.                    <tr><th>Verified</th><td>{'✓' if g_v else '✗'}</td></tr>
  406.                    <tr><th>Public Entry</th><td>{'✓' if public_entry else '✗'}</td></tr>
  407.                    <tr><th>Builders Club Only</th><td>{'✓' if bc_only else '✗'}</td></tr>
  408.                    {f'<tr><th>Shout</th><td class="prewrap">{h(shout_text)}</td></tr>' if shout_text else ''}
  409.                  </table>
  410.                </details>
  411.                """
  412.             )
  413.         return "\n".join(parts) if parts else "<p class='small'>No groups found.</p>"
  414.  
  415.     def outfit_cards(arr: List[Dict[str, Any]]) -> str:
  416.         parts: List[str] = []
  417.         for o in arr or []:
  418.             oid = o.get("id")
  419.             oname = o.get("name") or ""
  420.             thumb = outfit_thumb_paths.get(int(oid)) if isinstance(oid, int) else None
  421.             assets = []
  422.             det = outfit_details.get(int(oid), {}) if isinstance(oid, int) else {}
  423.             a_list = det.get("assets") or det.get("assetsList") or det.get("Assets") or []
  424.             for a in a_list:
  425.                 aid = a.get("id") or a.get("assetId")
  426.                 aname = a.get("name") or a.get("assetName") or ""
  427.                 assets.append({"id": aid, "name": aname})
  428.             rows = []
  429.             for a in assets:
  430.                 aid = a.get("id")
  431.                 an = a.get("name")
  432.                 link = f"https://www.roblox.com/catalog/{aid}" if aid else ""
  433.                 ath = asset_thumb_paths.get(int(aid)) if isinstance(aid, int) else None
  434.                 meta = asset_thumb_meta.get(int(aid), {}) if isinstance(aid, int) else {}
  435.                 state = meta.get("state") or ("NoThumb" if not ath else "OK")
  436.                 img_html = f"<img src='{h(ath)}' alt='Asset {h(aid)}' style='height:75px'>" if ath else "<span class='small'>No thumbnail</span>"
  437.                 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>")
  438.             inner = table(["Thumb","Name","AssetId","Status","Link"], rows) if rows else "<p class='small'>No items in outfit.</p>"
  439.             img_html = f"<img src='{h(thumb)}' alt='Outfit {h(oid)}' style='height:120px'>" if thumb else "<span class='small'>No outfit thumbnail</span>"
  440.             parts.append(
  441.                 f"""
  442.                <details class="outfit-card">
  443.                  <summary>
  444.                    <strong>{h(oname)}</strong>
  445.                    <span class="pill">OutfitId {h(oid)}</span>
  446.                    <span class="pill">Items {len(assets)}</span>
  447.                    <span class="pill thumb">{img_html}</span>
  448.                  </summary>
  449.                  {inner}
  450.                </details>
  451.                """
  452.             )
  453.         return "\n".join(parts) if parts else "<p class='small'>No outfits found.</p>"
  454.  
  455.     def inventory_sections(inv: Dict[str, List[Dict[str, Any]]]) -> str:
  456.         if not inv:
  457.             return "<p class='small'>No assets found.</p>"
  458.         missed = set(profile.get("inventoryMissedCategories", []))
  459.         parts: List[str] = []
  460.         for t, items in inv.items():
  461.             flag = t in missed
  462.             if flag:
  463.                 inner = "<p class='small warn'>Data not collected due to rate limiting.</p>"
  464.             elif not items:
  465.                 inner = "<p class='small'>No assets found.</p>"
  466.             else:
  467.                 rows = []
  468.                 for it in items:
  469.                     aid = it.get("assetId") or it.get("id")
  470.                     aname = (it.get("name") or it.get("assetName") or "").strip()
  471.                     link = f"https://www.roblox.com/catalog/{aid}" if aid else ""
  472.                     rows.append(f"<tr><td>{h(aname)}</td><td>{h(aid)}</td><td><a href='{h(link)}' target='_blank'>catalog</a></td></tr>")
  473.                 inner = table(["Name","AssetId","Link"], rows)
  474.             pill = " <span class='pill warn'>Rate limited</span>" if flag else ""
  475.             parts.append(f"<details open><summary><strong>{h(t)}</strong> — {len(items)} item(s){pill}</summary>{inner}</details>")
  476.         return "\n".join(parts)
  477.  
  478.     friends = profile.get("friends", [])
  479.     followers = profile.get("followers", [])
  480.     following = profile.get("following", [])
  481.     groups = profile.get("groups", [])
  482.     outfits = profile.get("outfits", [])
  483.     inventory = profile.get("inventory", {})
  484.     created = profile.get("created")
  485.     is_banned = profile.get("isBanned")
  486.     display = profile.get("displayName")
  487.     uname = profile.get("username")
  488.     uid = profile.get("userId")
  489.     pf_link = f"https://www.roblox.com/users/{uid}/profile"
  490.  
  491.     html_doc = f"""<!doctype html>
  492. <html>
  493. <head>
  494. <meta charset="utf-8">
  495. <title>Roblox Profile Report — {h(uname)}</title>
  496. <style>
  497. body {{
  498.  font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
  499.  margin: 24px;
  500. }}
  501. header {{ margin-bottom: 16px; }}
  502. summary {{ cursor: pointer; padding: 6px 0; }}
  503. table.compact {{
  504.  border-collapse: collapse;
  505.  width: auto;
  506.  max-width: 100%;
  507.  margin: 6px 0 16px;
  508.  display: inline-table;
  509. }}
  510. td, th {{
  511.  border: 1px solid #ddd;
  512.  padding: 6px 8px;
  513.  font-size: 14px;
  514.  vertical-align: top;
  515. }}
  516. th {{
  517.  background: #f5f5f5;
  518.  text-align: left;
  519. }}
  520. tbody tr:nth-child(even) td {{
  521.  background: #fafafa;
  522. }}
  523. tr.subhead th {{
  524.  background: #f0f0f0;
  525.  font-weight: 600;
  526. }}
  527. tr.empty td {{
  528.  color: #666;
  529.  font-style: italic;
  530. }}
  531. .meta span {{ display: inline-block; margin-right: 12px; }}
  532. .badge {{
  533.  display: inline-block; padding: 2px 8px;
  534.  border-radius: 10px; font-size: 12px; background:#eee; margin-left:8px;
  535. }}
  536. .small {{ color:#666; font-size:12px; }}
  537. .small.warn {{ color:#a13; }}
  538. a {{ text-decoration: none; }}
  539. a:hover {{ text-decoration: underline; }}
  540. img {{ display:block; }}
  541. .group-card, .outfit-card {{
  542.  border: 1px solid #ddd; border-radius: 8px; padding: 8px 12px; margin: 8px 0;
  543. }}
  544. .group-card > summary, .outfit-card > summary {{
  545.  display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
  546. }}
  547. .pill {{
  548.  background: #f5f5f5; border: 1px solid #e2e2e2; border-radius: 999px; padding: 2px 8px; font-size: 12px;
  549. }}
  550. .pill.link {{ background: #eef; border-color: #dde; }}
  551. .pill.thumb {{ background: transparent; border: 0; padding: 0; }}
  552. .pill.warn {{ background: #ffe9d6; border-color: #f6c08f; color: #8a3b00; }}
  553. .kv {{
  554.  border-collapse: collapse; width: auto; margin-top: 8px;
  555. }}
  556. .kv th, .kv td {{
  557.  border: 1px solid #ddd; padding: 6px 8px; font-size: 14px; vertical-align: top;
  558. }}
  559. .kv th {{
  560.  background: #fafafa; white-space: nowrap;
  561. }}
  562. .prewrap {{
  563.  white-space: pre-wrap; word-break: break-word; max-width: 72ch;
  564. }}
  565. </style>
  566. </head>
  567. <body>
  568. <header>
  569.  <h1>Roblox Profile Report — {h(uname)} <span class="badge">{h(display)}</span></h1>
  570.  <div class="meta">
  571.    <span><strong>UserId:</strong> {h(uid)}</span>
  572.    <span><strong>Created:</strong> {h(created)}</span>
  573.    <span><strong>isBanned:</strong> {h(is_banned)}</span>
  574.    <span><a href="{h(pf_link)}" target="_blank">Open profile</a></span>
  575.  </div>
  576.  <p class="small">Generated at {h(profile.get('fetchedAt'))} — Source: Roblox public APIs</p>
  577.  <p><strong>Bio:</strong> {h(profile.get('bio'))}</p>
  578. </header>
  579.  
  580. {html_section("Friends", ["Name", "UserId", "Link"], rows_for_friends(friends), len(friends))}
  581. {html_section("Followers", ["UserId", "Link"], rows_for_simple_users(followers), len(followers))}
  582. {html_section("Following", ["UserId", "Link"], rows_for_simple_users(following), len(following))}
  583.  
  584. <details open>
  585.  <summary><strong>Groups</strong> — {len(groups)} item(s)</summary>
  586.  {groups_cards(groups)}
  587. </details>
  588.  
  589. <details open>
  590.  <summary><strong>Outfits</strong> — {len(outfits)} item(s)</summary>
  591.  {outfit_cards(outfits)}
  592. </details>
  593.  
  594. <details open>
  595.  <summary><strong>Inventory (by AssetType)</strong> — {sum(len(v) for v in inventory.values())} item(s)</summary>
  596.  {inventory_sections(inventory)}
  597. </details>
  598.  
  599. </body></html>
  600. """
  601.     return html_doc
  602.  
  603. def parse_args() -> argparse.Namespace:
  604.     p = argparse.ArgumentParser(description="Roblox profile scraper → JSON + HTML + CSV (public APIs)")
  605.     p.add_argument("--max-items", type=int, default=50000)
  606.     p.add_argument("--rate", type=float, default=0.30)
  607.     p.add_argument("--retries", type=int, default=5)
  608.     p.add_argument("--timeout", type=int, default=20)
  609.     p.add_argument("--outdir", default=".")
  610.     return p.parse_args()
  611.  
  612. def download_images(urls: Dict[int, Optional[str]], prefix: str, outdir: Path, headers: Dict[str,str]) -> Dict[int, str]:
  613.     ensure_dir(outdir)
  614.     saved: Dict[int, str] = {}
  615.     for k, url in urls.items():
  616.         if not url:
  617.             continue
  618.         img_path = outdir / f"{prefix}_{k}.png"
  619.         try:
  620.             r = requests.get(url, headers=headers, timeout=30)
  621.             if r.status_code == 200 and r.content:
  622.                 with img_path.open("wb") as f:
  623.                     f.write(r.content)
  624.                 saved[k] = str(img_path)
  625.             else:
  626.                 print(f"[{prefix}] failed {k} status {r.status_code}")
  627.         except requests.RequestException as e:
  628.             print(f"[{prefix}] error {k} {e.__class__.__name__}")
  629.     print(f"[{prefix}] saved {len(saved)}")
  630.     return saved
  631.  
  632. def main():
  633.     args = parse_args()
  634.     username = input("Enter the Roblox username to scrape: ").strip()
  635.     base_outdir = Path(args.outdir).resolve()
  636.     base_outdir.mkdir(parents=True, exist_ok=True)
  637.     client = RobloxClient(rate_limit_seconds=args.rate, max_retries=args.retries, timeout=args.timeout, max_items_per_stream=max(1, int(args.max_items)))
  638.     profile = build_profile(client, username, max_items=client.max_items_per_stream)
  639.     json_path = base_outdir / f"{profile['username']}_profile.json"
  640.     with json_path.open("w", encoding="utf-8") as f:
  641.         json.dump(profile, f, ensure_ascii=False, indent=2)
  642.     print(f"Wrote {json_path}")
  643.     csv_dir = base_outdir / f"{profile['username']}_exports"
  644.     ensure_dir(csv_dir)
  645.     save_csv_exports(profile['username'], profile, csv_dir)
  646.     print(f"Wrote CSV exports to {csv_dir}")
  647.     outfits = profile.get("outfits", [])
  648.     outfit_ids = [o.get("id") for o in outfits if isinstance(o.get("id"), int)]
  649.     img_dir = csv_dir / "images"
  650.     outfit_thumbs = client.fetch_outfit_thumbnails(outfit_ids)
  651.     local_outfit_thumbs = download_images(outfit_thumbs, "outfit", img_dir, client.headers)
  652.     outfit_details: Dict[int, Dict[str, Any]] = {}
  653.     all_asset_ids: List[int] = []
  654.     for idx, oid in enumerate(outfit_ids, 1):
  655.         det = client.fetch_outfit_details(int(oid))
  656.         outfit_details[int(oid)] = det or {}
  657.         assets = det.get("assets") or det.get("Assets") or []
  658.         for a in assets:
  659.             aid = a.get("id") or a.get("assetId")
  660.             if isinstance(aid, int):
  661.                 all_asset_ids.append(aid)
  662.         print(f"[outfit-details] {idx}/{len(outfit_ids)} id={oid} assets={len(assets) if isinstance(assets, list) else 0}")
  663.     all_asset_ids = sorted({a for a in all_asset_ids if isinstance(a, int)})
  664.     print(f"[asset-thumbs] requesting {len(all_asset_ids)}")
  665.     asset_thumb_meta = client.fetch_asset_thumbnails(all_asset_ids, size="150x150")
  666.     asset_thumb_urls = {aid: meta.get("url") for aid, meta in asset_thumb_meta.items()}
  667.     local_asset_thumbs = download_images(asset_thumb_urls, "asset", img_dir, client.headers)
  668.     html_path = base_outdir / f"{profile['username']}_report.html"
  669.     rel_outfit_thumbs: Dict[int, str] = {k: str(Path(v).relative_to(base_outdir)) for k, v in local_outfit_thumbs.items()}
  670.     rel_asset_thumbs: Dict[int, str] = {k: str(Path(v).relative_to(base_outdir)) for k, v in local_asset_thumbs.items()}
  671.     html_report = to_html_report(profile['username'], profile, rel_outfit_thumbs, outfit_details, rel_asset_thumbs, asset_thumb_meta)
  672.     with html_path.open("w", encoding="utf-8") as f:
  673.         f.write(html_report)
  674.     print(f"Wrote {html_path}")
  675.  
  676. if __name__ == "__main__":
  677.     main()
  678.  
  679.  
Advertisement
Add Comment
Please, Sign In to add comment