import axios, { AxiosRequestConfig, AxiosError } from 'axios'; import { BinaryData, JSDOM } from 'jsdom'; import { Player, Activity, Skill, Stats, Skills, BH, Clues, Gamemode, SkillName, PlayerSkillRow, ActivityName, PlayerActivityRow, Bosses, GetStatsOptions, HiscoresResponse } from './types'; import { getStatsURL, SKILLS, BH_MODES, CLUES, getPlayerTableURL, getSkillPageURL, GAMEMODES, ACTIVITIES, numberFromElement, rsnFromElement, getActivityPageURL, httpGet, BOSSES, InvalidFormatError, PlayerNotFoundError, HiScoresError, validateRSN, FORMATTED_SKILL_NAMES, FORMATTED_BH_NAMES, FORMATTED_CLUE_NAMES, FORMATTED_BOSS_NAMES, FORMATTED_LEAGUE_POINTS, FORMATTED_LMS, FORMATTED_PVP_ARENA, FORMATTED_SOUL_WARS, FORMATTED_RIFTS_CLOSED } from './utils'; /** * Gets a player's stats from the official OSRS JSON endpoint. * * @param rsn Username of the player. * @param mode Gamemode to fetch ranks for. * @param config Optional axios request config object. * @returns Official JSON stats object. */ export async function getOfficialStats( rsn: string, mode: Gamemode = 'main', config?: AxiosRequestConfig ): Promise { validateRSN(rsn); const url = getStatsURL(mode, rsn, true); try { const response = await httpGet(url, config); return response.data; } catch (err) { if (!axios.isAxiosError(err)) throw err; if (err.response?.status === 404) throw new PlayerNotFoundError(); throw new HiScoresError(); } } /** * Screen scrapes the hiscores to get the formatted rsn of a player. * * @param rsn Username of the player. * @param config Optional axios request config object. * @returns Formatted version of the rsn. */ export async function getRSNFormat( rsn: string, config?: AxiosRequestConfig ): Promise { validateRSN(rsn); const url = getPlayerTableURL('main', rsn); try { const response = await httpGet( url, config ); const dom = new JSDOM(response.data); const anchor = dom.window.document.querySelector( '.personal-hiscores__row.personal-hiscores__row--type-highlight a' ); if (anchor) { return rsnFromElement(anchor); } throw new PlayerNotFoundError(); } catch { throw new HiScoresError(); } } /** * Parses official JSON object of raw stats and returns a stats object. * * @param csv Raw JSON from the official OSRS API. * @returns Parsed stats object. */ export function parseJsonStats(json: HiscoresResponse): Stats { const getActivity = (formattedName: string): Activity => { const hiscoresActivity = json.activities.find( // We must match on name here since id is not guaranteed to be the same between updates ({ name }) => name.toLowerCase() === formattedName.toLowerCase() ); return { rank: hiscoresActivity?.rank ?? -1, score: hiscoresActivity?.score ?? -1 }; }; const reduceActivity = >( keys: Key[], formattedNames: Record ): Reduced => keys.reduce( (reducer, key) => ({ ...reducer, [key]: getActivity(formattedNames[key]) }), {} as Reduced ); const skills = SKILLS.reduce((skillsObject, skillName) => { const hiscoresSkill = json.skills.find( // We must match on name here since id is not guaranteed to be the same between updates ({ name }) => name.toLowerCase() === FORMATTED_SKILL_NAMES[skillName].toLowerCase() ); return { ...skillsObject, [skillName]: { rank: hiscoresSkill?.rank ?? -1, level: hiscoresSkill?.level ?? -1, xp: hiscoresSkill?.xp ?? -1 } }; }, {} as Skills); const bountyHunter = reduceActivity(BH_MODES, FORMATTED_BH_NAMES); const clues = reduceActivity(CLUES, FORMATTED_CLUE_NAMES); const bosses = reduceActivity(BOSSES, FORMATTED_BOSS_NAMES); const leaguePoints = getActivity(FORMATTED_LEAGUE_POINTS); const lastManStanding = getActivity(FORMATTED_LMS); const pvpArena = getActivity(FORMATTED_PVP_ARENA); const soulWarsZeal = getActivity(FORMATTED_SOUL_WARS); const riftsClosed = getActivity(FORMATTED_RIFTS_CLOSED); const stats: Stats = { skills, leaguePoints, bountyHunter, lastManStanding, pvpArena, soulWarsZeal, riftsClosed, clues, bosses }; return stats; } /** * Parses CSV string of raw stats and returns a stats object. * * @param csv Raw CSV from the official OSRS API. * @returns Parsed stats object. */ export function parseStats(csv: string): Stats { const splitCSV = csv .split('\n') .filter((entry) => !!entry) .map((stat) => stat.split(',')); if ( splitCSV.length !== SKILLS.length + BH_MODES.length + CLUES.length + BOSSES.length + 5 ) { throw new InvalidFormatError(); } const skillObjects: Skill[] = splitCSV .filter((stat) => stat.length === 3) .map((stat) => { const [rank, level, xp] = stat; const skill: Skill = { rank: parseInt(rank, 10), level: parseInt(level, 10), xp: parseInt(xp, 10) }; return skill; }); const activityObjects: Activity[] = splitCSV .filter((stat) => stat.length === 2) .map((stat) => { const [rank, score] = stat; const activity: Activity = { rank: parseInt(rank, 10), score: parseInt(score, 10) }; return activity; }); const [leaguePoints] = activityObjects.splice(0, 1); const bhObjects = activityObjects.splice(0, BH_MODES.length); const clueObjects = activityObjects.splice(0, CLUES.length); const [lastManStanding, pvpArena, soulWarsZeal, riftsClosed] = activityObjects.splice(0, 4); const bossObjects = activityObjects.splice(0, BOSSES.length); const skills: Skills = skillObjects.reduce((prev, curr, index) => { const newSkills = { ...prev }; newSkills[SKILLS[index]] = curr; return newSkills; }, {} as Skills); const bountyHunter: BH = bhObjects.reduce((prev, curr, index) => { const newBH = { ...prev }; newBH[BH_MODES[index]] = curr; return newBH; }, {} as BH); const clues: Clues = clueObjects.reduce((prev, curr, index) => { const newClues = { ...prev }; newClues[CLUES[index]] = curr; return newClues; }, {} as Clues); const bosses: Bosses = bossObjects.reduce((prev, curr, index) => { const newBosses = { ...prev }; newBosses[BOSSES[index]] = curr; return newBosses; }, {} as Bosses); const stats: Stats = { skills, leaguePoints, bountyHunter, lastManStanding, pvpArena, soulWarsZeal, riftsClosed, clues, bosses }; return stats; } /** * Fetches stats from the OSRS API and consolidates the info into a player object. * * **Note:** This function will make up to 5 separate network requests. * As such, it is highly subject to the performance of the official OSRS API. * * @param rsn Username of the player. * @returns Player object. */ export async function getStats( rsn: string, options?: GetStatsOptions ): Promise { validateRSN(rsn); const otherGamemodes = options?.otherGamemodes ?? [ 'ironman', 'hardcore', 'ultimate' ]; const shouldGetFormattedRsn = options?.shouldGetFormattedRsn ?? true; const main = await getOfficialStats(rsn, 'main', options?.axiosConfigs?.main); const emptyResponse: HiscoresResponse = { skills: [], activities: [] }; const getModeStats = async ( mode: Extract ): Promise => otherGamemodes.includes(mode) ? getOfficialStats(rsn, mode, options?.axiosConfigs?.[mode]) .catch(err => err) : emptyResponse; const formattedName = shouldGetFormattedRsn ? await getRSNFormat(rsn, options?.axiosConfigs?.rsn).catch( () => undefined ) : undefined; const player: Player = { name: formattedName ?? rsn, mode: 'main', dead: false, deulted: false, deironed: false }; player.main = parseJsonStats(main); const iron = await getModeStats('ironman'); if (iron) { player.ironman = parseJsonStats(iron); const hc = await getModeStats('hardcore'); const ult = await getModeStats('ultimate'); if (hc) { player.mode = 'hardcore'; player.hardcore = parseJsonStats(hc); if ( player.ironman.skills.overall.xp !== player.hardcore.skills.overall.xp ) { player.dead = true; player.mode = 'ironman'; } if ( player.main.skills.overall.xp !== player.ironman.skills.overall.xp ) { player.deironed = true; player.mode = 'main'; } } else if (ult) { player.mode = 'ultimate'; player.ultimate = parseJsonStats(ult); if ( player.ironman.skills.overall.xp !== player.ultimate.skills.overall.xp ) { player.deulted = true; player.mode = 'ironman'; } if ( player.main.skills.overall.xp !== player.ironman.skills.overall.xp ) { player.deironed = true; player.mode = 'main'; } } else { player.mode = 'ironman'; if ( player.main.skills.overall.xp !== player.ironman.skills.overall.xp ) { player.deironed = true; player.mode = 'main'; } } } return player; } /** * Fetches stats from the OSRS API and returns them as an object. * * @param rsn Username of the player. * @param mode Gamemode to fetch ranks for. * @param config Optional axios request config object. * @returns Stats object. */ export async function getStatsByGamemode( rsn: string, mode: Gamemode = 'main', config?: AxiosRequestConfig ): Promise { validateRSN(rsn); if (!GAMEMODES.includes(mode)) { throw Error('Invalid game mode'); } const response = await getOfficialStats(rsn, mode, config); const stats = parseJsonStats(response); return stats; } export async function getSkillPage( skill: SkillName, mode: Gamemode = 'main', page: number = 1, config?: AxiosRequestConfig ): Promise { if (!GAMEMODES.includes(mode)) { throw Error('Invalid game mode'); } else if (!Number.isInteger(page) || page < 1) { throw Error('Page must be an integer greater than 0'); } else if (!SKILLS.includes(skill)) { throw Error('Invalid skill'); } const url = getSkillPageURL(mode, skill, page); const response = await httpGet( url, config ); const dom = new JSDOM(response.data); const playersHTML = dom.window.document.querySelectorAll( '.personal-hiscores__row' ); const players: PlayerSkillRow[] = []; playersHTML.forEach((row) => { const rankEl = row.querySelector('td'); const nameEl = row.querySelector('td a'); const levelEl = row.querySelector('td.left + td'); const xpEl = row.querySelector('td.left + td + td'); const isDead = !!row.querySelector('td img'); players.push({ name: rsnFromElement(nameEl), rank: numberFromElement(rankEl), level: numberFromElement(levelEl), xp: numberFromElement(xpEl), dead: isDead }); }); return players; } /** * Screen scrapes a hiscores page of an activity or boss and returns an array of up to 25 players. * * @param activity Name of the activity or boss to fetch hiscores for. * @param mode Gamemode to fetch ranks for. * @param page Page number. * @param config Optional axios request config object. * @returns Array of `PlayerActivityRow` objects. */ export async function getActivityPage( activity: ActivityName, mode: Gamemode = 'main', page: number = 1, config?: AxiosRequestConfig ): Promise { if (!GAMEMODES.includes(mode)) { throw Error('Invalid game mode'); } else if (!Number.isInteger(page) || page < 1) { throw Error('Page must be an integer greater than 0'); } else if (!ACTIVITIES.includes(activity)) { throw Error('Invalid activity'); } const url = getActivityPageURL(mode, activity, page); const response = await httpGet( url, config ); const dom = new JSDOM(response.data); const playersHTML = dom.window.document.querySelectorAll( '.personal-hiscores__row' ); const players: PlayerActivityRow[] = []; playersHTML.forEach((row) => { const rankEl = row.querySelector('td'); const nameEl = row.querySelector('td a'); const scoreEl = row.querySelector('td.left + td'); const isDead = !!row.querySelector('td img'); players.push({ name: rsnFromElement(nameEl), rank: numberFromElement(rankEl), score: numberFromElement(scoreEl), dead: isDead }); }); return players; }