diff options
-rw-r--r-- | .gitignore | 2 | ||||
-rw-r--r-- | README.md | 5 | ||||
-rw-r--r-- | cleaners/player.ts | 28 | ||||
-rw-r--r-- | cleaners/rank.ts | 66 | ||||
-rw-r--r-- | cleaners/skyblock/minions.ts | 70 | ||||
-rw-r--r-- | cleaners/skyblock/stats.ts | 73 | ||||
-rw-r--r-- | cleaners/socialmedia.ts | 14 | ||||
-rw-r--r-- | hypixel.ts | 284 | ||||
-rw-r--r-- | hypixelApi.ts | 154 | ||||
-rw-r--r-- | hypixelCached.ts | 240 | ||||
-rw-r--r-- | index.ts | 27 | ||||
-rw-r--r-- | mojang.ts | 59 | ||||
-rw-r--r-- | package.json | 25 | ||||
-rw-r--r-- | tsconfig.json | 10 | ||||
-rw-r--r-- | util.ts | 80 |
15 files changed, 1137 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..97aca2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +node_modules
\ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..de9da31 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# SkyBlock API + +The thing that powers skyblock.matdoes.dev + +Basically this is Slothpixel but more specialized diff --git a/cleaners/player.ts b/cleaners/player.ts new file mode 100644 index 0000000..c3afd6f --- /dev/null +++ b/cleaners/player.ts @@ -0,0 +1,28 @@ +import { CleanBasicProfile, cleanPlayerSkyblockProfiles, Included } from '../hypixel' +import { CleanSocialMedia, parseSocialMedia } from './socialmedia' +import { CleanRank, parseRank } from './rank' +import { HypixelPlayer } from '../hypixelApi' +import { undashUuid } from '../util' + +export interface CleanBasicPlayer { + uuid: string + username: string +} + +export interface CleanPlayer extends CleanBasicPlayer { + rank: CleanRank + socials: CleanSocialMedia + profiles?: CleanBasicProfile[] +} + +export async function cleanPlayerResponse(data: HypixelPlayer): Promise<CleanPlayer> { + // Cleans up a 'player' api response + console.log('cleanPlayerResponse', data.stats.SkyBlock.profiles) + return { + uuid: undashUuid(data.uuid), + username: data.displayname, + rank: parseRank(data), + socials: parseSocialMedia(data.socialMedia), + profiles: cleanPlayerSkyblockProfiles(data.stats.SkyBlock.profiles) + } +} diff --git a/cleaners/rank.ts b/cleaners/rank.ts new file mode 100644 index 0000000..928373a --- /dev/null +++ b/cleaners/rank.ts @@ -0,0 +1,66 @@ +import { HypixelPlayer } from '../hypixelApi' +import { colorCodeFromName, minecraftColorCodes } from '../util' + +const rankColors: { [ name: string ]: string } = { + 'NONE': '7', + 'VIP': 'a', + 'VIP+': 'a', + 'MVP': 'b', + 'MVP+': 'b', + 'MVP++': '6', + 'YOUTUBE': 'c', + 'HELPER': '9', + 'MODERATOR': '2', + 'ADMIN': 'c' +} + +export interface CleanRank { + name: string, + color: string | null, + colored: string | null, +} + +/** Response cleaning (reformatting to be nicer) */ +export function parseRank({ + packageRank, + newPackageRank, + monthlyPackageRank, + rankPlusColor, + rank, + prefix +}: HypixelPlayer): CleanRank { + let name + let color + let colored + if (prefix) { // derive values from prefix + colored = prefix + color = minecraftColorCodes[colored.match(/§./)[0][1]] + name = colored.replace(/§./g, '').replace(/[\[\]]/g, '') + } else { + name = rank + || newPackageRank.replace('_PLUS', '+') + || packageRank.replace('_PLUS', '+') + || monthlyPackageRank + + // MVP++ is called Superstar for some reason + if (name === 'SUPERSTAR') name = 'MVP++' + // YouTube rank is called YouTuber, change this to the proper name + else if (name === 'YOUTUBER') name = 'YOUTUBE' + + const plusColor = colorCodeFromName(rankPlusColor) + color = minecraftColorCodes[rankColors[name]] + const rankColorPrefix = rankColors[name] ? '§' + rankColors[name] : '' + const nameWithoutPlus = name.split('+')[0] + const plusesInName = '+'.repeat(name.split('+').length - 1) + console.log(plusColor, nameWithoutPlus, plusesInName) + if (plusColor && plusesInName.length >= 1) + colored = `${rankColorPrefix}[${nameWithoutPlus}§${plusColor}${plusesInName}${rankColorPrefix}]` + else + colored = `${rankColorPrefix}[${name}]` + } + return { + name, + color, + colored + } +} diff --git a/cleaners/skyblock/minions.ts b/cleaners/skyblock/minions.ts new file mode 100644 index 0000000..da69634 --- /dev/null +++ b/cleaners/skyblock/minions.ts @@ -0,0 +1,70 @@ +import { maxMinion } from '../../hypixel' + +export interface CleanMinion { + name: string, + levels: boolean[] +} + + +/** + * Clean the minions provided by Hypixel + * @param minionsRaw The minion data provided by the Hypixel API + */ +export function cleanMinions(minionsRaw: string[]): CleanMinion[] { + const minions: CleanMinion[] = [] + for (const minionRaw of minionsRaw ?? []) { + // do some regex magic to get the minion name and level + // examples of potential minion names: CLAY_11, PIG_1, MAGMA_CUBE_4 + const minionName = minionRaw.split(/_\d/)[0].toLowerCase() + const minionLevel = parseInt(minionRaw.split(/\D*_/)[1]) + let matchingMinion = minions.find(m => m.name === minionName) + if (!matchingMinion) { + // if the minion doesnt already exist in the minions array, then create it + matchingMinion = { + name: minionName, + levels: new Array(maxMinion).fill(false) + } + minions.push(matchingMinion) + } + while (minionLevel > matchingMinion.levels.length) + // if hypixel increases the minion level, this will increase with it + matchingMinion.levels.push(false) + + // set the minion at that level to true + matchingMinion.levels[minionLevel - 1] = true + } + return minions +} + +/** + * Combine multiple arrays of minions into one, useful when getting the minions for members + * @param minions An array of arrays of minions + */ +export function combineMinionArrays(minions: CleanMinion[][]): CleanMinion[] { + const resultMinions: CleanMinion[] = [] + + for (const memberMinions of minions) { + for (const minion of memberMinions) { + // this is a reference, so we can directly modify the attributes for matchingMinionReference + // and they'll be in the resultMinions array + const matchingMinionReference = resultMinions.find(m => m.name === minion.name) + if (!matchingMinionReference) { + // if the minion name isn't already in the array, add it! + resultMinions.push(minion) + } else { + + // This should never happen, but in case the length of `minion.levels` is longer than + // `matchingMinionReference.levels`, then it should be extended to be equal length + while (matchingMinionReference.levels.length < minion.levels.length) + matchingMinionReference.levels.push(null) + + for (let i = 0; i < minion.levels.length; i++) { + if (minion.levels[i]) + matchingMinionReference.levels[i] = true + } + } + } + } + + return resultMinions +}
\ No newline at end of file diff --git a/cleaners/skyblock/stats.ts b/cleaners/skyblock/stats.ts new file mode 100644 index 0000000..07d9133 --- /dev/null +++ b/cleaners/skyblock/stats.ts @@ -0,0 +1,73 @@ +const statCategories: { [ key: string ]: string[] | null } = { // sorted in order of importance + 'deaths': ['deaths_', 'deaths'], + 'kills': ['kills_', 'kills'], + 'fishing': ['items_fished_', 'items_fished'], + 'auctions': ['auctions_'], + 'races': ['_best_time'], + 'misc': null // everything else goes here +} + +interface statCategory { + category: string, + name: string +} + +function categorizeStat(statNameRaw: string): statCategory { + // 'deaths_void' + for (const statCategory in statCategories) { + // 'deaths' + const statCategoryMatchers = statCategories[statCategory] + if (statCategoryMatchers == null) { + // If it's null, just go with this. Can only ever be 'misc' + return { + category: statCategory, + name: statNameRaw + } + } + for (const categoryMatch of statCategoryMatchers) { + // ['deaths_'] + let trailingEnd = categoryMatch[0] == '_' + let trailingStart = categoryMatch.substr(-1) == '_' + if (trailingStart && statNameRaw.startsWith(categoryMatch)) { + return { + category: statCategory, + name: statNameRaw.substr(categoryMatch.length) + } + } else if (trailingEnd && statNameRaw.endsWith(categoryMatch)) { + return { + category: statCategory, + name: statNameRaw.substr(0, categoryMatch.length) + } + } else if (statNameRaw == categoryMatch) { + // if it matches exactly, we don't know the name. will be defaulted to category later on + return { + category: statCategory, + name: null + } + } + } + } + // this should never happen, as it'll default to misc and return if nothing is found + return { + category: null, + name: statNameRaw + } +} + +export interface CleanProfileStats { + [ category: string ]: { + [ stat: string ]: any + total?: any + } +} + +export function cleanProfileStats(statsRaw): CleanProfileStats { + // TODO: add type for statsRaw (probably in hypixelApi.ts since its coming from there) + const stats: CleanProfileStats = {} + for (let statNameRaw in statsRaw) { + let { category: statCategory, name: statName } = categorizeStat(statNameRaw) + if (!stats[statCategory]) stats[statCategory] = {} + stats[statCategory][statName || 'total'] = statsRaw[statNameRaw] + } + return stats +} diff --git a/cleaners/socialmedia.ts b/cleaners/socialmedia.ts new file mode 100644 index 0000000..c1f9551 --- /dev/null +++ b/cleaners/socialmedia.ts @@ -0,0 +1,14 @@ +import { HypixelPlayerSocialMedia } from "../hypixelApi"; + +export interface CleanSocialMedia { + discord: string | null + forums: string | null +} + +export function parseSocialMedia(socialMedia: HypixelPlayerSocialMedia): CleanSocialMedia { + return { + discord: socialMedia?.links?.DISCORD || null, + forums: socialMedia?.links?.HYPIXEL || null + } +} + diff --git a/hypixel.ts b/hypixel.ts new file mode 100644 index 0000000..f17dc9e --- /dev/null +++ b/hypixel.ts @@ -0,0 +1,284 @@ +import { CleanMinion, cleanMinions, combineMinionArrays } from './cleaners/skyblock/minions' +import { CleanProfileStats, cleanProfileStats } from './cleaners/skyblock/stats' +import { CleanPlayer, cleanPlayerResponse } from './cleaners/player' +import { chooseApiKey, HypixelPlayerStatsSkyBlockProfiles, HypixelResponse, sendApiRequest } from './hypixelApi' +import * as cached from './hypixelCached' + +export type Included = 'profiles' | 'player' | 'stats' + +// the interval at which the "last_save" parameter updates in the hypixel api, this is 3 minutes +export const saveInterval = 60 * 3 * 1000 + +// the highest level a minion can be +export const maxMinion = 11 + +/** + * Send a request to api.hypixel.net using a random key, clean it up to be more useable, and return it + */ +export async function sendCleanApiRequest({ path, args }, included?: Included[], cleaned=true) { + const key = await chooseApiKey() + const rawResponse = await sendApiRequest({ path, key, args }) + if (rawResponse.throttled) { + // if it's throttled, wait a second and try again + console.log('throttled :/') + await new Promise(resolve => setTimeout(resolve, 1000)) + return await sendCleanApiRequest({ path, args }, included, cleaned) + } + if (cleaned) { + // if it needs to clean the response, call cleanResponse + return await cleanResponse({ path, data: rawResponse }, included=included) + } else { + // this is provided in case the caller wants to do the cleaning itself + // used in skyblock/profile, as cleaning the entire profile would use too much cpu + return rawResponse + } +} + +export interface CleanBasicMember { + uuid: string + username: string + last_save: number + first_join: number +} + +interface CleanMember extends CleanBasicMember { + stats?: CleanProfileStats + minions?: CleanMinion[] +} + +async function cleanSkyBlockProfileMemberResponse(member, included: Included[] = null): Promise<CleanMember> { + // Cleans up a member (from skyblock/profile) + // profiles.members[] + const statsIncluded = included == null || included.includes('stats') + return { + uuid: member.uuid, + username: await cached.usernameFromUser(member.uuid), + last_save: member.last_save, + first_join: member.first_join, + // last_death: ??? idk how this is formatted, + stats: statsIncluded ? cleanProfileStats(member.stats) : undefined, + minions: statsIncluded ? cleanMinions(member.crafted_generators) : undefined, + } +} + + +export interface CleanMemberProfilePlayer extends CleanPlayer { + // The profile name may be different for each player, so we put it here + profileName: string + first_join: number + last_save: number + bank?: { + balance: number + history: any[] + } +} + +export interface CleanMemberProfile { + member: CleanMemberProfilePlayer + profile: { + + } +} + +export interface CleanProfile extends CleanBasicProfile { + members?: CleanBasicMember[] +} + +export interface CleanFullProfile extends CleanProfile { + members: CleanMember[] + bank?: { + balance: number + history: any[] + } + minions: CleanMinion[] +} + +/** Return a `CleanProfile` instead of a `CleanFullProfile`, useful when we need to get members but don't want to waste much ram */ +async function cleanSkyblockProfileResponseLighter(data): Promise<CleanProfile> { + // We use Promise.all so it can fetch all the usernames at once instead of waiting for the previous promise to complete + const promises: Promise<CleanMember>[] = [] + + for (const memberUUID in data.members) { + const memberRaw = data.members[memberUUID] + memberRaw.uuid = memberUUID + // we pass an empty array to make it not check stats + promises.push(cleanSkyBlockProfileMemberResponse(memberRaw, [])) + } + + const cleanedMembers: CleanMember[] = await Promise.all(promises) + + return { + uuid: data.profile_id, + name: data.cute_name, + members: cleanedMembers, + } +} + +/** This function is very costly and shouldn't be called often. Use cleanSkyblockProfileResponseLighter if you don't need all the data */ +async function cleanSkyblockProfileResponse(data: any): Promise<CleanFullProfile> { + const cleanedMembers: CleanMember[] = [] + + for (const memberUUID in data.members) { + const memberRaw = data.members[memberUUID] + memberRaw.uuid = memberUUID + const member: CleanMember = await cleanSkyBlockProfileMemberResponse(memberRaw, ['stats']) + cleanedMembers.push(member) + } + + const memberMinions: CleanMinion[][] = [] + + for (const member of cleanedMembers) { + memberMinions.push(member.minions) + } + const minions: CleanMinion[] = combineMinionArrays(memberMinions) + + // return more detailed info + return { + uuid: data.profile_id, + name: data.cute_name, + members: cleanedMembers, + bank: { + balance: data?.banking?.balance ?? 0, + + // TODO: make transactions good + history: data?.banking?.transactions ?? [] + }, + minions + } +} + +/** A basic profile that only includes the profile uuid and name */ +export interface CleanBasicProfile { + uuid: string + + // the name depends on the user, so its sometimes not included + name?: string +} + +export function cleanPlayerSkyblockProfiles(rawProfiles: HypixelPlayerStatsSkyBlockProfiles): CleanBasicProfile[] { + let profiles: CleanBasicProfile[] = [] + for (const profile of Object.values(rawProfiles)) { + profiles.push({ + uuid: profile.profile_id, + name: profile.cute_name + }) + } + console.log('cleanPlayerSkyblockProfiles', profiles) + return profiles +} + +/** Convert an array of raw profiles into clean profiles */ +async function cleanSkyblockProfilesResponse(data: any[]): Promise<CleanProfile[]> { + const cleanedProfiles: CleanProfile[] = [] + for (const profile of data) { + let cleanedProfile = await cleanSkyblockProfileResponseLighter(profile) + cleanedProfiles.push(cleanedProfile) + } + return cleanedProfiles +} + +async function cleanResponse({ path, data }: { path: string, data: HypixelResponse }, included?: Included[]) { + // Cleans up an api response + switch (path) { + case 'player': return await cleanPlayerResponse(data.player) + case 'skyblock/profile': return await cleanSkyblockProfileResponse(data.profile) + case 'skyblock/profiles': return await cleanSkyblockProfilesResponse(data.profiles) + } +} + +/* ----------------------------- */ + +export interface UserAny { + user?: string + uuid?: string + username?: string +} + +export interface CleanUser { + player: any + profiles?: any + activeProfile?: string + online?: boolean +} + + +/** + * Higher level function that requests the api for a user, and returns the cleaned response + * This is safe to fetch many times because the results are cached! + * @param included lets you choose what is returned, so there's less processing required on the backend + * used inclusions: player, profiles + */ +export async function fetchUser({ user, uuid, username }: UserAny, included: Included[]=['player']): Promise<CleanUser> { + if (!uuid) { + // If the uuid isn't provided, get it + uuid = await cached.uuidFromUser(user || username) + } + + const includePlayers = included.includes('player') + const includeProfiles = included.includes('profiles') + + let profilesData: CleanProfile[] + let basicProfilesData: CleanBasicProfile[] + let playerData: CleanPlayer + + if (includePlayers) { + playerData = await cached.fetchPlayer(uuid) + // if not including profiles, include lightweight profiles just in case + if (!includeProfiles) + basicProfilesData = playerData.profiles + playerData.profiles = undefined + } + if (includeProfiles) { + profilesData = await cached.fetchSkyblockProfiles(uuid) + } + + let activeProfile: CleanProfile = null + let lastOnline: number = 0 + + if (includeProfiles) { + for (const profile of profilesData) { + const member = profile.members.find(member => member.uuid === uuid) + if (member.last_save > lastOnline) { + lastOnline = member.last_save + activeProfile = profile + } + } + } + return { + player: playerData ?? null, + profiles: profilesData ?? basicProfilesData, + activeProfile: includeProfiles ? activeProfile?.uuid : undefined, + online: includeProfiles ? lastOnline > (Date.now() - saveInterval): undefined + } +} + +/** + * Fetch a CleanMemberProfile from a user and string + * This is safe to use many times as the results are cached! + * @param user A username or uuid + * @param profile A profile name or profile uuid + */ +export async function fetchMemberProfile(user: string, profile: string): Promise<CleanMemberProfile> { + const playerUuid = await cached.uuidFromUser(user) + const profileUuid = await cached.fetchProfileUuid(user, profile) + + const player = await cached.fetchPlayer(playerUuid) + + const cleanProfile = await cached.fetchProfile(playerUuid, profileUuid) + + const member = cleanProfile.members.find(m => m.uuid === playerUuid) + + return { + member: { + profileName: cleanProfile.name, + first_join: member.first_join, + last_save: member.last_save, + + // add all other data relating to the hypixel player, such as username, rank, etc + ...player + }, + profile: { + minions: cleanProfile.minions + } + } +} diff --git a/hypixelApi.ts b/hypixelApi.ts new file mode 100644 index 0000000..e91d4f5 --- /dev/null +++ b/hypixelApi.ts @@ -0,0 +1,154 @@ +import fetch from 'node-fetch' +import { jsonToQuery, shuffle } from './util' +import { Agent } from 'https' +require('dotenv').config() + +// We need to create an agent to prevent memory leaks and to only do dns lookups once +const httpsAgent = new Agent({ + keepAlive: true +}) + +/* Lower level code related to the Hypixel api */ + +const apiKeys = process.env.keys.split(' ') + +interface KeyUsage { + remaining: number + limit: number + reset: number +} + +const apiKeyUsage: { [ key: string ]: KeyUsage } = {} + + +const baseHypixelAPI = 'https://api.hypixel.net' + +/** Choose the best current API key */ +export function chooseApiKey(): string { + // find the api key with the lowest amount of uses + let bestKeyUsage: KeyUsage = null + let bestKey: string = null + for (var key of shuffle(apiKeys)) { + const keyUsage = apiKeyUsage[key] + + // if the key has never been used before, use it + if (!keyUsage) return key + + // if the key has reset since the last use, set the remaining count to the default + if (Date.now() > keyUsage.reset) + keyUsage.remaining = keyUsage.limit + + // if this key has more uses remaining than the current known best one, save it + if (!bestKeyUsage || keyUsage.remaining > bestKeyUsage.remaining) { + bestKeyUsage = keyUsage + bestKey = key + } + } + return bestKey +} + +export interface HypixelResponse { + [key: string]: any | { + success: boolean + throttled?: boolean + } +} + + +export interface HypixelPlayerStatsSkyBlockProfiles { + [ uuid: string ]: { + profile_id: string + cute_name: string + } +} + +interface HypixelPlayerStatsSkyBlock { + profiles: HypixelPlayerStatsSkyBlockProfiles +} + +export interface HypixelPlayerSocialMedia { + YOUTUBE?: string + prompt: boolean + links: { + DISCORD?: string + HYPIXEL?: string + } +} + +export interface HypixelPlayer { + _id: string + achievementsOneTime: string[] + displayname: string + + firstLogin: number, + lastLogin: number, + lastLogout: number + + knownAliases: string[], + knownAliasesLower: string[] + + networkExp: number + playername: string + stats: { + SkyBlock: HypixelPlayerStatsSkyBlock + [ name: string ]: any + }, + timePlaying: number, + uuid: string, + achievements: { [ name: string ]: number }, + petConsumables: { [ name: string ]: number }, + vanityMeta: { + packages: string[] + }, + + language: string, + userLanguage?: string + + packageRank?: string + newPackageRank?: string + rankPlusColor?: string + monthlyPackageRank?: string + rank?: string + prefix?: string + + claimed_potato_talisman?: number + skyblock_free_cookie?: number + + socialMedia?: HypixelPlayerSocialMedia +} + + +/** Send an HTTP request to the Hypixel API */ +export async function sendApiRequest({ path, key, args }): Promise<HypixelResponse> { + console.log('sending api request to', path, args) + // Send a raw http request to api.hypixel.net, and return the parsed json + + if (key) + // If there's an api key, add it to the arguments + args.key = key + + // Construct a url from the base api url, path, and arguments + const fetchUrl = baseHypixelAPI + '/' + path + '?' + jsonToQuery(args) + + const fetchResponse = await fetch( + fetchUrl, + { agent: () => httpsAgent } + ) + + if (fetchResponse.headers['ratelimit-limit']) + // remember how many uses it has + apiKeyUsage[key] = { + remaining: fetchResponse.headers['ratelimit-remaining'], + limit: fetchResponse.headers['ratelimit-limit'], + reset: Date.now() + parseInt(fetchResponse.headers['ratelimit-reset']) * 1000 + } + + const fetchJsonParsed = await fetchResponse.json() + if (fetchJsonParsed.throttle) { + apiKeyUsage[key].remaining = 0 + console.log('throttled :(') + return { throttled: true } + } + return fetchJsonParsed +} + diff --git a/hypixelCached.ts b/hypixelCached.ts new file mode 100644 index 0000000..5fe65a3 --- /dev/null +++ b/hypixelCached.ts @@ -0,0 +1,240 @@ +import NodeCache from 'node-cache' +import * as mojang from './mojang' +import * as hypixel from './hypixel' +import { CleanPlayer } from './cleaners/player' +import { CleanBasicProfile, CleanFullProfile, CleanProfile } from './hypixel' +import { undashUuid } from './util' + +/** +Hypixel... but with caching +All the caching in this project is done here! +*/ + + +// cache usernames for 4 hours +const usernameCache = new NodeCache({ + stdTTL: 60 * 60 * 4, + checkperiod: 60, + useClones: false, +}) + +const basicProfilesCache = new NodeCache({ + stdTTL: 60 * 10, + checkperiod: 60, + useClones: false, +}) + +const playerCache = new NodeCache({ + stdTTL: 60, + checkperiod: 10, + useClones: false, +}) + +const profileCache = new NodeCache({ + stdTTL: 30, + checkperiod: 10, + useClones: false, +}) + +const profilesCache = new NodeCache({ + stdTTL: 60 * 3, + checkperiod: 10, + useClones: false, +}) + +const profileNameCache = new NodeCache({ + stdTTL: 60 * 60, + checkperiod: 60, + useClones: false, +}) + +/** + * Fetch the uuid from a user + * @param user A user can be either a uuid or a username + */ +export async function uuidFromUser(user: string): Promise<string> { + if (usernameCache.has(undashUuid(user))) + // check if the uuid is a key + return undashUuid(user) + + // check if the username is a value + const uuidToUsername: {[ key: string ]: string} = usernameCache.mget(usernameCache.keys()) + for (const [ uuid, username ] of Object.entries(uuidToUsername)) { + if (user.toLowerCase() === username.toLowerCase()) + return uuid + } + + // not cached, actually fetch mojang api now + let { uuid, username } = await mojang.mojangDataFromUser(user) + + // remove dashes from the uuid so its more normal + uuid = undashUuid(uuid) + usernameCache.set(uuid, username) + return uuid +} + +/** + * Fetch the username from a user + * @param user A user can be either a uuid or a username + */ +export async function usernameFromUser(user: string): Promise<string> { + if (usernameCache.has(undashUuid(user))) { + return usernameCache.get(undashUuid(user)) + } + + let { uuid, username } = await mojang.mojangDataFromUser(user) + uuid = undashUuid(uuid) + usernameCache.set(uuid, username) + return username +} + + +export async function fetchPlayer(user: string): Promise<CleanPlayer> { + const playerUuid = await uuidFromUser(user) + + if (playerCache.has(playerUuid)) { + console.log('cache hit! fetchPlayer', playerUuid) + return playerCache.get(playerUuid) + } + + const cleanPlayer: CleanPlayer = await hypixel.sendCleanApiRequest({ + path: 'player', + args: { uuid: playerUuid } + }) + playerCache.set(playerUuid, cleanPlayer) + return cleanPlayer +} + + +export async function fetchSkyblockProfiles(playerUuid: string): Promise<CleanProfile[]> { + if (profilesCache.has(playerUuid)) { + console.log('cache hit! fetchSkyblockProfiles', playerUuid) + return profilesCache.get(playerUuid) + } + + const profiles: CleanFullProfile[] = await hypixel.sendCleanApiRequest({ + path: 'skyblock/profiles', + args: { + uuid: playerUuid + }}, + ) + + const basicProfiles: CleanProfile[] = [] + + // create the basicProfiles array + for (const profile of profiles) { + const basicProfile: CleanProfile = { + name: profile.name, + uuid: profile.uuid, + members: profile.members.map(m => { + return { + uuid: m.uuid, + username: m.username, + first_join: m.first_join, + last_save: m.last_save + } + }) + } + basicProfiles.push(basicProfile) + } + + // cache the profiles + profilesCache.set(playerUuid, basicProfiles) + + return basicProfiles +} + +/** Fetch an array of `BasicProfile`s */ +async function fetchBasicProfiles(user: string): Promise<CleanBasicProfile[]> { + const playerUuid = await uuidFromUser(user) + if (basicProfilesCache.has(playerUuid)) { + console.log('cache hit! fetchBasicProfiles') + return basicProfilesCache.get(playerUuid) + } + const player = await fetchPlayer(playerUuid) + const profiles = player.profiles + basicProfilesCache.set(playerUuid, profiles) + + console.log(player) + + // cache the profile names and uuids to profileNameCache because we can + for (const profile of profiles) + profileNameCache.set(`${playerUuid}.${profile.uuid}`, profile.name) + + return profiles +} + +/** + * Fetch a profile UUID from its name and user + * @param user A username or uuid + * @param profile A profile name or profile uuid + */ +export async function fetchProfileUuid(user: string, profile: string) { + const profiles = await fetchBasicProfiles(user) + + const profileUuid = undashUuid(profile) + + for (const p of profiles) { + if (p.name.toLowerCase() === profileUuid.toLowerCase()) + return undashUuid(p.uuid) + else if (undashUuid(p.uuid) === undashUuid(profileUuid)) + return undashUuid(p.uuid) + } +} + +/** + * Fetch an entire profile from the user and profile data + * @param user A username or uuid + * @param profile A profile name or profile uuid + */ +export async function fetchProfile(user: string, profile: string): Promise<CleanFullProfile> { + const profileUuid = await fetchProfileUuid(user, profile) + + if (profileCache.has(profileUuid)) { + console.log('cache hit! fetchProfile') + // we have the profile cached, return it :) + return profileCache.get(profileUuid) + } + + const profileName = await fetchProfileName(user, profile) + + const cleanProfile: CleanFullProfile = await hypixel.sendCleanApiRequest({ + path: 'skyblock/profile', + args: { + profile: profileUuid + } + }) + + // we know the name from fetchProfileName, so set it here + cleanProfile.name = profileName + + profileCache.set(profileUuid, cleanProfile) + + return cleanProfile +} + +/** + * Fetch the name of a profile from the user and profile uuid + * @param user A player uuid or username + * @param profile A profile uuid or name + */ +export async function fetchProfileName(user: string, profile: string): Promise<string> { + // we're fetching the profile and player uuid again in case we were given a name, but it's cached so it's not much of a problem + const profileUuid = await fetchProfileUuid(user, profile) + const playerUuid = await uuidFromUser(user) + + if (profileNameCache.has(`${playerUuid}.${profileUuid}`)) { + // Return the profile name if it's cached + console.log('cache hit! fetchProfileName') + return profileNameCache.get(`${playerUuid}.${profileUuid}`) + } + + const basicProfiles = await fetchBasicProfiles(playerUuid) + let profileName + for (const basicProfile of basicProfiles) + if (basicProfile.uuid === playerUuid) + profileName = basicProfile.name + + profileNameCache.set(`${playerUuid}.${profileUuid}`, profileName) + return profileName +}
\ No newline at end of file diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..af5095b --- /dev/null +++ b/index.ts @@ -0,0 +1,27 @@ +import express from 'express' +import { fetchMemberProfile, fetchUser } from './hypixel' +import { fetchProfile } from './hypixelCached' + +const app = express() + + +app.get('/', async(req, res) => { + res.json({ ok: true }) +}) + +app.get('/player/:user', async(req, res) => { + res.json( + await fetchUser( + { user: req.params.user }, + ['profiles', 'player'] + ) + ) +}) + +app.get('/player/:user/:profile', async(req, res) => { + res.json( + await fetchMemberProfile(req.params.user, req.params.profile) + ) +}) + +app.listen(8080, () => console.log('App started :)'))
\ No newline at end of file diff --git a/mojang.ts b/mojang.ts new file mode 100644 index 0000000..746f674 --- /dev/null +++ b/mojang.ts @@ -0,0 +1,59 @@ +import fetch from 'node-fetch' +import { Agent } from 'https' + +// We need to create an agent to prevent memory leaks +const httpsAgent = new Agent({ + keepAlive: true +}) + +interface AshconHistoryItem { + username: string + changed_at?: string +} + +interface AshconTextures { + custom: boolean + slim: boolean + skin: { url: string, data: string } + raw: { value: string, signature: string } +} + +interface AshconResponse { + uuid: string + username: string + username_history: AshconHistoryItem[] + textures: AshconTextures + created_at?: string +} + +/** + * Get mojang api data from ashcon.app + */ +export async function mojangDataFromUser(user: string): Promise<AshconResponse> { + console.log('cache miss :( mojangDataFromUser', user) + const fetchResponse = await fetch( + 'https://api.ashcon.app/mojang/v2/user/' + user, + { agent: () => httpsAgent } + ) + return await fetchResponse.json() +} + +/** + * Fetch the uuid from a user + * @param user A user can be either a uuid or a username + */ +export async function uuidFromUser(user: string): Promise<string> { + const fetchJSON = await mojangDataFromUser(user) + return fetchJSON.uuid.replace(/-/g, '') +} + +/** + * Fetch the username from a user + * @param user A user can be either a uuid or a username + */ +export async function usernameFromUser(user: string): Promise<string> { + // get a minecraft uuid from a username, using ashcon.app's mojang api + const fetchJSON = await mojangDataFromUser(user) + return fetchJSON.username +} + diff --git a/package.json b/package.json new file mode 100644 index 0000000..eedc492 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "skyblock-api", + "version": "1.0.0", + "description": "Wrapper for the Hypixel SkyBlock API", + "main": "index.ts", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/mat-1/skyblock-api.git" + }, + "keywords": [ + "hypixel", + "minecraft", + "hypixel", + "skyblock" + ], + "author": "mat", + "license": "ISC", + "bugs": { + "url": "https://github.com/mat-1/skyblock-api/issues" + }, + "homepage": "https://github.com/mat-1/skyblock-api#readme" +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ed4a062 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "module": "commonjs", + "lib": ["esnext", "dom", "DOM.Iterable"], + "target": "esnext", + "esModuleInterop": true + }, + "include": ["**/*"], + "exclude": ["node_modules"], +}
\ No newline at end of file @@ -0,0 +1,80 @@ +/* Utility functions (not related to Hypixel) */ + +export function undashUuid(uuid: string): string { + return uuid.replace(/-/g, '') +} + + + +export function queryToJson(queryString) { + var query = {}; + var pairs = (queryString[0] === '?' ? queryString.substr(1) : queryString).split('&'); + for (var i = 0; i < pairs.length; i++) { + var pair = pairs[i].split('='); + query[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || ''); + } + return query; +} + +export function jsonToQuery(data) { + return Object.entries(data || {}).map(e => e.join('=')).join('&') +} + +export function shuffle(a) { + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + + +export const minecraftColorCodes: { [ key: string ]: string } = { + '0': '#000000', + '1': '#0000be', + '2': '#00be00', + '3': '#00bebe', + '4': '#be0000', // red + '5': '#be00be', + '6': '#ffaa00', // gold + '7': '#bebebe', + '8': '#3f3f3f', + '9': '#3f3ffe', + 'a': '#3ffe3f', + 'b': '#3ffefe', + 'c': '#fe3f3f', // light red + 'd': '#fe3ffe', + 'e': '#fefe3f', + 'f': '#ffffff', + + 'black': '#000000', + 'dark_blue': '#0000be', + 'dark_green': '#00be00', + 'dark_aqua': '#00bebe', + 'dark_red': '#be0000', // red + 'dark_purple': '#be00be', + 'gold': '#ffaa00', // gold + 'gray': '#bebebe', + 'dark_gray': '#3f3f3f', + 'blue': '#3f3ffe', + 'green': '#3ffe3f', + 'aqua': '#3ffefe', + 'red': '#fe3f3f', // light red + 'light_purple': '#fe3ffe', + 'yellow': '#fefe3f', + 'white': '#ffffff', +} + +/** + * Converts a color name to the code + * For example: blue -> 9 + * @param colorName The name of the color (blue, red, aqua, etc) + */ +export function colorCodeFromName(colorName: string): string { + const hexColor = minecraftColorCodes[colorName.toLowerCase()] + for (const key in minecraftColorCodes) { + const value = minecraftColorCodes[key] + if (key.length === 1 && value === hexColor) + return key + } +}
\ No newline at end of file |