diff --git a/.gitignore b/.gitignore index 0f29872..130c78f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .idea/ node_modules -/lib \ No newline at end of file +/lib +.vscode \ No newline at end of file diff --git a/__tests__/b0atyStats.csv b/__tests__/b0atyStats.csv new file mode 100644 index 0000000..ddedb69 --- /dev/null +++ b/__tests__/b0atyStats.csv @@ -0,0 +1,98 @@ +3923,2277,792976020 +23024,99,29963318 +12244,99,28197154 +2261,99,134139762 +4074,99,112054504 +7166,99,101083246 +3747,99,15110682 +4516,99,50824811 +126288,99,13353110 +24800,99,16366466 +49293,99,13453226 +20784,99,16654289 +16376,99,19114676 +42069,99,14055874 +9305,99,16527135 +25135,99,14657869 +35831,99,13233130 +6197,99,15739327 +48215,99,13963265 +4786,99,33830972 +3402,99,62531484 +4314,99,22733194 +14826,99,17399368 +1009,99,17989158 +-1,-1 +22270,10 +10481,3 +149217,8 +103672,7 +253,9508 +713,825 +71,6000 +1943,1600 +42364,287 +467,436 +1634,360 +12937,2521 +-1,-1 +5222,14780 +3007,1060 +2578,2192 +84703,594 +19500,143 +3335,1830 +191697,5 +27457,293 +43607,58 +17384,1905 +171607,33 +2074,501 +78908,50 +135830,26 +48781,287 +2112,2000 +138748,41 +31297,707 +40908,698 +31273,714 +76034,25 +2391,688 +17409,1228 +60841,474 +1895,2140 +19948,159 +1855,2046 +46423,579 +107879,1908 +9791,920 +2050,1444 +848,33 +24797,620 +4157,744 +4154,500 +880,217 +15860,251 +60424,119 +100429,26 +358191,9 +1131,1569 +33908,273 +165651,7 +64278,201 +1533,583 +748,700 +21700,250 +13200,7 +6622,3066 +87093,35 +2449,502 +14541,2 +-1,-1 +1048,1432 +138,7096 +58203,51 +13949,2780 +827440,62 +153191,25 +216690,274 \ No newline at end of file diff --git a/__tests__/b0atyStats.json b/__tests__/b0atyStats.json new file mode 100644 index 0000000..a3829f6 --- /dev/null +++ b/__tests__/b0atyStats.json @@ -0,0 +1,194 @@ +{ + "skills": [ + { + "id": 0, + "name": "Overall", + "rank": 3923, + "level": 2277, + "xp": 792976020 + }, + { "id": 1, "name": "Attack", "rank": 23024, "level": 99, "xp": 29963318 }, + { "id": 2, "name": "Defence", "rank": 12244, "level": 99, "xp": 28197154 }, + { "id": 3, "name": "Strength", "rank": 2261, "level": 99, "xp": 134139762 }, + { + "id": 4, + "name": "Hitpoints", + "rank": 4074, + "level": 99, + "xp": 112054504 + }, + { "id": 5, "name": "Ranged", "rank": 7166, "level": 99, "xp": 101083246 }, + { "id": 6, "name": "Prayer", "rank": 3747, "level": 99, "xp": 15110682 }, + { "id": 7, "name": "Magic", "rank": 4516, "level": 99, "xp": 50824811 }, + { "id": 8, "name": "Cooking", "rank": 126288, "level": 99, "xp": 13353110 }, + { + "id": 9, + "name": "Woodcutting", + "rank": 24800, + "level": 99, + "xp": 16366466 + }, + { + "id": 10, + "name": "Fletching", + "rank": 49293, + "level": 99, + "xp": 13453226 + }, + { "id": 11, "name": "Fishing", "rank": 20784, "level": 99, "xp": 16654289 }, + { + "id": 12, + "name": "Firemaking", + "rank": 16376, + "level": 99, + "xp": 19114676 + }, + { + "id": 13, + "name": "Crafting", + "rank": 42069, + "level": 99, + "xp": 14055874 + }, + { "id": 14, "name": "Smithing", "rank": 9305, "level": 99, "xp": 16527135 }, + { "id": 15, "name": "Mining", "rank": 25135, "level": 99, "xp": 14657869 }, + { + "id": 16, + "name": "Herblore", + "rank": 35831, + "level": 99, + "xp": 13233130 + }, + { "id": 17, "name": "Agility", "rank": 6197, "level": 99, "xp": 15739327 }, + { + "id": 18, + "name": "Thieving", + "rank": 48215, + "level": 99, + "xp": 13963265 + }, + { "id": 19, "name": "Slayer", "rank": 4786, "level": 99, "xp": 33830972 }, + { "id": 20, "name": "Farming", "rank": 3402, "level": 99, "xp": 62531484 }, + { + "id": 21, + "name": "Runecraft", + "rank": 4314, + "level": 99, + "xp": 22733194 + }, + { "id": 22, "name": "Hunter", "rank": 14826, "level": 99, "xp": 17399368 }, + { + "id": 23, + "name": "Construction", + "rank": 1009, + "level": 99, + "xp": 17989158 + } + ], + "activities": [ + { "id": 0, "name": "League Points", "rank": -1, "score": -1 }, + { "id": 1, "name": "Bounty Hunter - Hunter", "rank": 22270, "score": 10 }, + { "id": 2, "name": "Bounty Hunter - Rogue", "rank": 10481, "score": 3 }, + { + "id": 3, + "name": "Bounty Hunter (Legacy) - Hunter", + "rank": 149217, + "score": 8 + }, + { + "id": 4, + "name": "Bounty Hunter (Legacy) - Rogue", + "rank": 103672, + "score": 7 + }, + { "id": 5, "name": "Clue Scrolls (all)", "rank": 253, "score": 9508 }, + { "id": 6, "name": "Clue Scrolls (beginner)", "rank": 713, "score": 825 }, + { "id": 7, "name": "Clue Scrolls (easy)", "rank": 71, "score": 6000 }, + { "id": 8, "name": "Clue Scrolls (medium)", "rank": 1943, "score": 1600 }, + { "id": 9, "name": "Clue Scrolls (hard)", "rank": 42364, "score": 287 }, + { "id": 10, "name": "Clue Scrolls (elite)", "rank": 467, "score": 436 }, + { "id": 11, "name": "Clue Scrolls (master)", "rank": 1634, "score": 360 }, + { "id": 12, "name": "LMS - Rank", "rank": 12937, "score": 2521 }, + { "id": 13, "name": "PvP Arena - Rank", "rank": -1, "score": -1 }, + { "id": 14, "name": "Soul Wars Zeal", "rank": 5222, "score": 14780 }, + { "id": 15, "name": "Rifts closed", "rank": 3007, "score": 1060 }, + { "id": 16, "name": "Abyssal Sire", "rank": 2578, "score": 2192 }, + { "id": 17, "name": "Alchemical Hydra", "rank": 84703, "score": 594 }, + { "id": 18, "name": "Artio", "rank": 19500, "score": 143 }, + { "id": 19, "name": "Barrows Chests", "rank": 3335, "score": 1830 }, + { "id": 20, "name": "Bryophyta", "rank": 191697, "score": 5 }, + { "id": 21, "name": "Callisto", "rank": 27457, "score": 293 }, + { "id": 22, "name": "Calvar'ion", "rank": 43607, "score": 58 }, + { "id": 23, "name": "Cerberus", "rank": 17384, "score": 1905 }, + { "id": 24, "name": "Chambers of Xeric", "rank": 171607, "score": 33 }, + { + "id": 25, + "name": "Chambers of Xeric: Challenge Mode", + "rank": 2074, + "score": 501 + }, + { "id": 26, "name": "Chaos Elemental", "rank": 78908, "score": 50 }, + { "id": 27, "name": "Chaos Fanatic", "rank": 135830, "score": 26 }, + { "id": 28, "name": "Commander Zilyana", "rank": 48781, "score": 287 }, + { "id": 29, "name": "Corporeal Beast", "rank": 2112, "score": 2000 }, + { "id": 30, "name": "Crazy Archaeologist", "rank": 138748, "score": 41 }, + { "id": 31, "name": "Dagannoth Prime", "rank": 31297, "score": 707 }, + { "id": 32, "name": "Dagannoth Rex", "rank": 40908, "score": 698 }, + { "id": 33, "name": "Dagannoth Supreme", "rank": 31273, "score": 714 }, + { "id": 34, "name": "Deranged Archaeologist", "rank": 76034, "score": 25 }, + { "id": 35, "name": "Duke Sucellus", "rank": 2391, "score": 688 }, + { "id": 36, "name": "General Graardor", "rank": 17409, "score": 1228 }, + { "id": 37, "name": "Giant Mole", "rank": 60841, "score": 474 }, + { "id": 38, "name": "Grotesque Guardians", "rank": 1895, "score": 2140 }, + { "id": 39, "name": "Hespori", "rank": 19948, "score": 159 }, + { "id": 40, "name": "Kalphite Queen", "rank": 1855, "score": 2046 }, + { "id": 41, "name": "King Black Dragon", "rank": 46423, "score": 579 }, + { "id": 42, "name": "Kraken", "rank": 107879, "score": 1908 }, + { "id": 43, "name": "Kree'Arra", "rank": 9791, "score": 920 }, + { "id": 44, "name": "K'ril Tsutsaroth", "rank": 2050, "score": 1444 }, + { "id": 45, "name": "Mimic", "rank": 848, "score": 33 }, + { "id": 46, "name": "Nex", "rank": 24797, "score": 620 }, + { "id": 47, "name": "Nightmare", "rank": 4157, "score": 744 }, + { "id": 48, "name": "Phosani's Nightmare", "rank": 4154, "score": 500 }, + { "id": 49, "name": "Obor", "rank": 880, "score": 217 }, + { "id": 50, "name": "Phantom Muspah", "rank": 15860, "score": 251 }, + { "id": 51, "name": "Sarachnis", "rank": 60424, "score": 119 }, + { "id": 52, "name": "Scorpia", "rank": 100429, "score": 26 }, + { "id": 53, "name": "Skotizo", "rank": 358191, "score": 9 }, + { "id": 54, "name": "Spindel", "rank": 1131, "score": 1569 }, + { "id": 55, "name": "Tempoross", "rank": 33908, "score": 273 }, + { "id": 56, "name": "The Gauntlet", "rank": 165651, "score": 7 }, + { "id": 57, "name": "The Corrupted Gauntlet", "rank": 64278, "score": 201 }, + { "id": 58, "name": "The Leviathan", "rank": 1533, "score": 583 }, + { "id": 59, "name": "The Whisperer", "rank": 748, "score": 700 }, + { "id": 60, "name": "Theatre of Blood", "rank": 21700, "score": 250 }, + { + "id": 61, + "name": "Theatre of Blood: Hard Mode", + "rank": 13200, + "score": 7 + }, + { + "id": 62, + "name": "Thermonuclear Smoke Devil", + "rank": 6622, + "score": 3066 + }, + { "id": 63, "name": "Tombs of Amascut", "rank": 87093, "score": 35 }, + { + "id": 64, + "name": "Tombs of Amascut: Expert Mode", + "rank": 2449, + "score": 502 + }, + { "id": 65, "name": "TzKal-Zuk", "rank": 14541, "score": 2 }, + { "id": 66, "name": "TzTok-Jad", "rank": -1, "score": -1 }, + { "id": 67, "name": "Vardorvis", "rank": 1048, "score": 1432 }, + { "id": 68, "name": "Venenatis", "rank": 138, "score": 7096 }, + { "id": 69, "name": "Vet'ion", "rank": 58203, "score": 51 }, + { "id": 70, "name": "Vorkath", "rank": 13949, "score": 2780 }, + { "id": 71, "name": "Wintertodt", "rank": 827440, "score": 62 }, + { "id": 72, "name": "Zalcano", "rank": 153191, "score": 25 }, + { "id": 73, "name": "Zulrah", "rank": 216690, "score": 274 } + ] +} diff --git a/__tests__/hiscores.test.ts b/__tests__/hiscores.test.ts index a9a1249..532b55c 100644 --- a/__tests__/hiscores.test.ts +++ b/__tests__/hiscores.test.ts @@ -13,7 +13,9 @@ import { getStatsURL, BOSSES, INVALID_FORMAT_ERROR, - BH_MODES + BH_MODES, + parseJsonStats, + HiscoresResponse } from '../src/index'; const B0ATY_NAME = 'B0ATY'; @@ -25,6 +27,10 @@ const LYNX_TITAN_FORMATTED_NAME = 'Lynx Titan'; const attackTopPage = readFileSync(`${__dirname}/attackTopPage.html`, 'utf8'); const b0atyNamePage = readFileSync(`${__dirname}/b0atyNamePage.html`, 'utf8'); +const b0atyStatsCsv = readFileSync(`${__dirname}/b0atyStats.csv`, 'utf8'); +const b0atyStatsJson: HiscoresResponse = JSON.parse( + readFileSync(`${__dirname}/b0atyStats.json`, 'utf8') +); const lynxTitanStats = readFileSync(`${__dirname}/lynxTitanStats.csv`, 'utf8'); const lynxTitanNamePage = readFileSync( `${__dirname}/lynxTitanNamePage.html`, @@ -184,7 +190,7 @@ test('Parse CSV to json', () => { hunterV2: { rank: 89914, score: 35 }, rogueV2: { rank: 99834, score: 25 }, hunter: { rank: 99831, score: 23 }, - rogue: { rank: 89912, score: 37 }, + rogue: { rank: 89912, score: 37 } }, lastManStanding: { rank: 4814, score: 898 }, pvpArena: { rank: 13, score: 4057 }, @@ -533,13 +539,13 @@ describe('Get stats options', () => { beforeEach(() => { axios.get = jest.fn( (url) => - new Promise((resolve) => + new Promise((resolve) => { resolve( url === getPlayerTableURL('main', rsn) ? { data: lynxTitanNamePage } : { status: 200, data: lynxTitanStats } - ) - ) + ); + }) ); axiosMock = axios.get as any; axiosMock.mockClear(); @@ -573,3 +579,9 @@ describe('Get stats options', () => { ).toBeFalsy(); }); }); + +test('CSV and JSON parsing outputs identical object', async () => { + const csvOutput = parseStats(b0atyStatsCsv); + const jsonOutput = parseJsonStats(b0atyStatsJson); + expect(csvOutput).toEqual(jsonOutput); +}); diff --git a/src/hiscores.ts b/src/hiscores.ts index 70a2737..2c45a1d 100644 --- a/src/hiscores.ts +++ b/src/hiscores.ts @@ -14,7 +14,8 @@ import { ActivityName, PlayerActivityRow, Bosses, - GetStatsOptions + GetStatsOptions, + HiscoresResponse } from './types'; import { getStatsURL, @@ -31,9 +32,43 @@ import { httpGet, BOSSES, INVALID_FORMAT_ERROR, - validateRSN + validateRSN, + PLAYER_NOT_FOUND_ERROR, + 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 { + throw Error(PLAYER_NOT_FOUND_ERROR); + } +} + /** * Screen scrapes the hiscores to get the formatted rsn of a player. * @@ -60,12 +95,81 @@ export async function getRSNFormat( if (anchor) { return rsnFromElement(anchor); } - throw Error('Player not found'); + throw Error(PLAYER_NOT_FOUND_ERROR); } catch { - throw Error('Player not found'); + throw Error(PLAYER_NOT_FOUND_ERROR); } } +/** + * 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 === formattedName + ); + 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 === FORMATTED_SKILL_NAMES[skillName] + ); + 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. * @@ -259,7 +363,7 @@ export async function getStats( return player; } - throw Error('Player not found'); + throw Error(PLAYER_NOT_FOUND_ERROR); } /** @@ -281,7 +385,7 @@ export async function getStatsByGamemode( } const response = await httpGet(getStatsURL(mode, rsn), config); if (response.status !== 200) { - throw Error('Player not found'); + throw Error(PLAYER_NOT_FOUND_ERROR); } const stats = parseStats(response.data); diff --git a/src/types.ts b/src/types.ts index 8ea701c..968db4f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -199,3 +199,41 @@ export interface GetStatsOptions { rsn?: AxiosRequestConfig; }; } + +export interface HiscoresCommon { + /** + * This field behaves more like an index than a true unique ID. + */ + id: number; + /** + * The display name of this skill / activity. + */ + name: string; + /** + * The player's official hiscores rank in this skill / activity. + */ + rank: number; +} + +export interface HiscoresSkill extends HiscoresCommon { + /** + * The player's current level in this skill. + */ + level: number; + /** + * The player's current experience in this skill. + */ + xp: number; +} + +export interface HiscoresActivity extends HiscoresCommon { + /** + * The player's current score in this activity. + */ + score: number; +} + +export interface HiscoresResponse { + skills: HiscoresSkill[]; + activities: HiscoresActivity[]; +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 113ce0f..6d24d71 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -9,6 +9,7 @@ import { export const BASE_URL = 'https://secure.runescape.com/m=hiscore_oldschool'; export const STATS_URL = 'index_lite.ws?player='; +export const JSON_STATS_URL = 'index_lite.json?player='; export const SCORES_URL = 'overall.ws?'; export type GamemodeUrl = { @@ -188,7 +189,7 @@ export const FORMATTED_BOSS_NAMES: FormattedBossNames = { krilTsutsaroth: "K'ril Tsutsaroth", mimic: 'Mimic', nex: 'Nex', - nightmare: 'The Nightmare of Ashihama', + nightmare: 'Nightmare', phosanisNightmare: "Phosani's Nightmare", obor: 'Obor', phantomMuspah: 'Phantom Muspah', @@ -273,10 +274,11 @@ export const FORMATTED_BH_NAMES: FormattedBHNames = { hunterV2: 'Bounty Hunter - Hunter' }; -export const FORMATTED_LMS = 'Last Man Standing'; -export const FORMATTED_PVP_ARENA = 'PvP Arena'; +export const FORMATTED_LMS = 'LMS - Rank'; +export const FORMATTED_PVP_ARENA = 'PvP Arena - Rank'; export const FORMATTED_SOUL_WARS = 'Soul Wars Zeal'; export const FORMATTED_LEAGUE_POINTS = 'League Points'; -export const FORMATTED_RIFTS_CLOSED = 'Rifts Closed'; +export const FORMATTED_RIFTS_CLOSED = 'Rifts closed'; export const INVALID_FORMAT_ERROR = 'Invalid hiscores format'; +export const PLAYER_NOT_FOUND_ERROR = 'Player not found'; diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 59f9c9a..ae9daeb 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -6,7 +6,8 @@ import { STATS_URL, SCORES_URL, SKILLS, - ACTIVITIES + ACTIVITIES, + JSON_STATS_URL } from './constants'; /** @@ -14,10 +15,13 @@ import { * * @param gamemode Gamemode to fetch ranks for. * @param rsn Username of the player. + * @param json If the JSON endpoint is desired instead of CSV. * @returns Encoded stats URL. */ -export const getStatsURL = (gamemode: Gamemode, rsn: string) => - `${GAMEMODE_URL[gamemode]}${STATS_URL}${encodeURIComponent(rsn)}`; +export const getStatsURL = (gamemode: Gamemode, rsn: string, json = false) => + `${GAMEMODE_URL[gamemode]}${ + json ? JSON_STATS_URL : STATS_URL + }${encodeURIComponent(rsn)}`; /** * Will generate a player table URL for the official OSRS hiscores website.