#!/usr/bin/env node /** * Roblox Account Stalker (Node.js ESM) * * Usage: * node roblox-stalk.js * node roblox-stalk.js --pretty * * As module: * import { stalk } from './roblox-stalk.js' * const data = await stalk('builderman') */ const BASE = { users: 'https://users.roblox.com', avatar: 'https://avatar.roblox.com', thumbnails: 'https://thumbnails.roblox.com', friends: 'https://friends.roblox.com', presence: 'https://presence.roblox.com', groups: 'https://groups.roblox.com', badges: 'https://badges.roblox.com', account: 'https://accountinformation.roblox.com', games: 'https://games.roblox.com', }; const UA = 'Mozilla/5.0 (compatible; RobloxStalker/1.3)'; async function request(url, options = {}) { const res = await fetch(url, { ...options, headers: { 'User-Agent': UA, Accept: 'application/json', ...(options.headers || {}), }, }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`HTTP ${res.status} → ${url}\n${text.slice(0, 300)}`); } return res.json(); } async function getUserIdByUsername(username) { const data = await request(`${BASE.users}/v1/usernames/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ usernames: [username], excludeBannedUsers: false, }), }); if (!data.data?.length) { throw new Error(`Username "${username}" tidak ditemukan`); } return data.data[0]; } async function getUser(userId) { return request(`${BASE.users}/v1/users/${userId}`); } async function getAvatar(userId) { return request(`${BASE.avatar}/v1/users/${userId}/avatar`); } async function getUserThumbnails(userId, size = '420x420') { const [full, headshot, bust] = await Promise.all([ request( `${BASE.thumbnails}/v1/users/avatar?userIds=${userId}&size=${size}&format=Png&isCircular=false` ), request( `${BASE.thumbnails}/v1/users/avatar-headshot?userIds=${userId}&size=${size}&format=Png&isCircular=false` ), request( `${BASE.thumbnails}/v1/users/avatar-bust?userIds=${userId}&size=${size}&format=Png&isCircular=false` ), ]); return { fullBody: full.data?.[0]?.imageUrl || null, fullBodyState: full.data?.[0]?.state || null, headshot: headshot.data?.[0]?.imageUrl || null, headshotState: headshot.data?.[0]?.state || null, bust: bust.data?.[0]?.imageUrl || null, bustState: bust.data?.[0]?.state || null, }; } async function getAssetThumbnails(assetIds, size = '150x150') { if (!assetIds?.length) return {}; const unique = [...new Set(assetIds.map(Number).filter(Boolean))]; const map = {}; const chunkSize = 80; for (let i = 0; i < unique.length; i += chunkSize) { const chunk = unique.slice(i, i + chunkSize); try { const data = await request( `${BASE.thumbnails}/v1/assets?assetIds=${chunk.join(',')}&size=${size}&format=Png&isCircular=false` ); for (const item of data.data || []) { map[item.targetId] = { imageUrl: item.imageUrl || null, state: item.state || null, }; } } catch { // skip } } return map; } async function getSocial(userId) { const [friends, followers, followings] = await Promise.all([ request(`${BASE.friends}/v1/users/${userId}/friends/count`).catch(() => ({ count: null })), request(`${BASE.friends}/v1/users/${userId}/followers/count`).catch(() => ({ count: null })), request(`${BASE.friends}/v1/users/${userId}/followings/count`).catch(() => ({ count: null })), ]); return { friends: friends.count ?? null, followers: followers.count ?? null, followings: followings.count ?? null, }; } async function getPresence(userId) { try { const data = await request(`${BASE.presence}/v1/presence/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userIds: [userId] }), }); return data.userPresences?.[0] || null; } catch { return null; } } async function getGroups(userId) { try { const data = await request(`${BASE.groups}/v2/users/${userId}/groups/roles`); return (data.data || []).map((g) => ({ id: g.group.id, name: g.group.name, memberCount: g.group.memberCount, role: g.role.name, rank: g.role.rank, isPrimary: g.isPrimaryAffiliation || false, })); } catch { return []; } } async function getRecentBadges(userId, limit = 10) { try { const data = await request( `${BASE.badges}/v1/users/${userId}/badges?limit=${limit}&sortOrder=Desc` ); return (data.data || []).map((b) => ({ id: b.id, name: b.name, description: b.description, displayName: b.displayName, awardedDate: b.awardedDate, iconImageId: b.iconImageId, })); } catch { return []; } } /** Username history (previous usernames) */ async function getUsernameHistory(userId, limit = 50) { try { // limit parameter kadang rate-limit, coba tanpa dulu lalu fallback const data = await request( `${BASE.users}/v1/users/${userId}/username-history?limit=${limit}&sortOrder=Desc` ).catch(() => request(`${BASE.users}/v1/users/${userId}/username-history`) ); return (data.data || []).map((h) => ({ name: h.name, // beberapa response punya timestamp, sebagian tidak })); } catch { return []; } } /** Official Roblox system badges */ async function getRobloxBadges(userId) { try { const data = await request( `${BASE.account}/v1/users/${userId}/roblox-badges` ); // response langsung array return (Array.isArray(data) ? data : data.data || []).map((b) => ({ id: b.id, name: b.name, description: b.description, imageUrl: b.imageUrl || null, })); } catch { return []; } } /** Favorite games (public) */ async function getFavoriteGames(userId, limit = 10) { try { const data = await request( `${BASE.games}/v2/users/${userId}/favorite/games?limit=${limit}&sortOrder=Desc` ); return (data.data || []).map((g) => ({ id: g.id, name: g.name, description: g.description || '', rootPlace: g.rootPlace ? { id: g.rootPlace.id, name: g.rootPlace.name } : null, placeVisits: g.placeVisits ?? null, })); } catch { return []; } } /** * Main stalk function * @param {string} username * @param {object} [options] * @param {string} [options.thumbnailSize='420x420'] * @param {string} [options.assetThumbnailSize='150x150'] */ export async function stalk(username, options = {}) { if (!username || typeof username !== 'string') { throw new Error('Username harus diisi'); } const clean = username.trim().replace(/^@/, ''); const userThumbSize = options.thumbnailSize || '420x420'; const assetThumbSize = options.assetThumbnailSize || '150x150'; const basic = await getUserIdByUsername(clean); const userId = basic.id; const [ user, avatar, userThumbs, social, presence, groups, badges, usernameHistory, robloxBadges, favoriteGames, ] = await Promise.all([ getUser(userId), getAvatar(userId), getUserThumbnails(userId, userThumbSize), getSocial(userId), getPresence(userId), getGroups(userId), getRecentBadges(userId), getUsernameHistory(userId), getRobloxBadges(userId), getFavoriteGames(userId), ]); const equippedRaw = avatar.assets || []; const emotesRaw = avatar.emotes || []; const allAssetIds = [ ...equippedRaw.map((a) => a.id), ...emotesRaw.map((e) => e.assetId), ]; const assetThumbs = await getAssetThumbnails(allAssetIds, assetThumbSize); // Urutan: fashion / accessory dulu, baru body part, terakhir animation const ASSET_ORDER = { // Fashion & accessories (prioritas tinggi) Hat: 10, HairAccessory: 11, FaceAccessory: 12, NeckAccessory: 13, ShoulderAccessory: 14, FrontAccessory: 15, BackAccessory: 16, WaistAccessory: 17, Shirt: 20, TShirt: 21, TShirtAccessory: 22, Pants: 23, PantsAccessory: 24, JacketAccessory: 25, SweaterAccessory: 26, ShortsAccessory: 27, LeftShoeAccessory: 28, RightShoeAccessory: 29, DressSkirtAccessory: 30, Gear: 35, // Face / head cosmetic Face: 40, Head: 41, DynamicHead: 42, // Body parts (di belakang) Torso: 50, LeftArm: 51, RightArm: 52, LeftLeg: 53, RightLeg: 54, // Animations (paling belakang) ClimbAnimation: 60, FallAnimation: 61, IdleAnimation: 62, JumpAnimation: 63, RunAnimation: 64, SwimAnimation: 65, WalkAnimation: 66, MoodAnimation: 67, }; const equippedAssets = equippedRaw .map((a) => { const typeName = a.assetType?.name || a.assetType || 'Unknown'; const thumb = assetThumbs[a.id] || {}; return { id: a.id, name: a.name, assetType: typeName, assetTypeId: a.assetType?.id ?? null, currentVersionId: a.currentVersionId, imageUrl: thumb.imageUrl || null, imageState: thumb.state || null, _sort: ASSET_ORDER[typeName] ?? 99, }; }) .sort((a, b) => a._sort - b._sort || a.name.localeCompare(b.name)) .map(({ _sort, ...rest }) => rest); const emotes = emotesRaw.map((e) => { const thumb = assetThumbs[e.assetId] || {}; return { assetId: e.assetId, assetName: e.assetName, position: e.position, imageUrl: thumb.imageUrl || null, imageState: thumb.state || null, }; }); return { success: true, fetchedAt: new Date().toISOString(), user: { id: user.id, name: user.name, displayName: user.displayName, description: user.description || '', created: user.created, isBanned: user.isBanned, hasVerifiedBadge: user.hasVerifiedBadge, externalAppDisplayName: user.externalAppDisplayName || null, social: { friends: social.friends, followers: social.followers, followings: social.followings, }, usernameHistory, }, avatar: { playerAvatarType: avatar.playerAvatarType, bodyColors: avatar.bodyColors, scales: avatar.scales, defaultShirtApplied: avatar.defaultShirtApplied, defaultPantsApplied: avatar.defaultPantsApplied, emotes, equippedAssets, }, thumbnails: { fullBody: userThumbs.fullBody, headshot: userThumbs.headshot, bust: userThumbs.bust, fullBodyState: userThumbs.fullBodyState, headshotState: userThumbs.headshotState, bustState: userThumbs.bustState, }, presence: presence ? { userPresenceType: presence.userPresenceType, lastLocation: presence.lastLocation, placeId: presence.placeId, rootPlaceId: presence.rootPlaceId, gameId: presence.gameId, universeId: presence.universeId, lastOnline: presence.lastOnline, } : null, groups, recentBadges: badges, robloxBadges, favoriteGames, profileUrl: `https://www.roblox.com/users/${userId}/profile`, }; } // ============ CLI ============ const isMain = import.meta.url === `file://${process.argv[1]?.replace(/\\/g, '/')}` || process.argv[1]?.endsWith('roblox-stalk.js'); if (isMain) { const args = process.argv.slice(2); const username = args.find((a) => !a.startsWith('-')); const pretty = args.includes('--pretty') || args.includes('-p'); if (!username) { console.error('Usage: node roblox-stalk.js [--pretty]'); process.exit(1); } stalk(username) .then((data) => { console.log(JSON.stringify(data, null, pretty ? 2 : 0)); }) .catch((err) => { console.error( JSON.stringify({ success: false, error: err.message }, null, 2) ); process.exit(1); }); }