UNPKG

leaguejs

Version:

Wrapper for the League of Legends API

814 lines (703 loc) 24.6 kB
'use strict'; const fs = require('fs'); const path = require('path'); const request = require('request'); const EventEmitter = require('events'); const Bluebird = require('bluebird'); const mkdirsSync = require('node-mkdirs'); const NodeCache = require('node-cache'); const {MatchUtil} = require('../util'); /** only the languages that are actually used by realms. * languages contains extra locales, which are not available on dataDragon */ const localesUsedForRealms = require('./DataDragon.constants').localesUsedForRealms; const languages = require('./DataDragon.constants').languages; const realmToLocaleMap = require('./DataDragon.constants').realmToLocaleMap; /** * Holds downloadPromises for each locale + for the download all task * The respective Promises will be resolved (or rejected) once the downloading process finished. * If a respective Promise for a locale / version is null, no download is active. * * If "all" is being used, the same promise will be set for every locale and the respective version. * * Format: { * <locale or "all"> : { * <ddragonVersion> : Promise<void> | null * } * } * @type {Object.<string, Object.<string, ?Bluebird>>} * */ let downloadPromises; let downloadUpdatePromise; let storageRoot = __dirname; const cache = new NodeCache({ // TODO: replace with settable cache? (see endpoint caches or rather use StaticDataEndpoint cache) stdTTL: 4 * 60 * 60, // 4h checkperiod: 5 * 60, // 1h errorOnMissing: false, useClones: true }); const events = new EventEmitter(); const eventIds = { /** emitted whenever a file was downloaded. * listener will receive * a message and info about the downloaded file * {locale, version, path }*/ DOWNLOAD: 'download', /** Should be used for regular errors an application might be interested in. * Errors that MUST be noted should use emitErrorCritical() instead */ ERROR: 'error', /** Should be emitted when a breaking error occhurs */ ERROR_CRITICAL: 'error-critical', /** Will be emitted for any log type, that does not have any listeners attached. */ LOG: 'log', LOG_ERROR: 'log-error', LOG_INFO: 'log-info', LOG_DEBUG: 'log-debug' }; // TODO(refactor): move events to own module function emitDownload(locale, version, destination) { events.emit(eventIds.DOWNLOAD, {locale, version, path: destination}); } function emitError(err) { if (events.listenerCount(eventIds.ERROR) !== 0) { events.emit(eventIds.ERROR, err); } else { console.error(err); // throw err; } } function emitErrorCritical(err) { if (events.listenerCount(eventIds.ERROR_CRITICAL) !== 0) { events.emit(eventIds.ERROR_CRITICAL, err); } else { emitError(err); // throw err; } } function emitLog(text, ...args) { events.emit(eventIds.LOG, text, ...args); } function emitLogError(text, ...args) { events.emit(eventIds.LOG_ERROR, text, ...args); if (events.listenerCount('log-error') === 0) { emitLog(text, ...args); } } function emitLogInfo(text, ...args) { events.emit(eventIds.LOG_INFO, text, ...args); if (events.listenerCount('log-info') === 0) { emitLog(text, ...args); } } function emitLogDebug(text, ...args) { events.emit(eventIds.LOG_DEBUG, text, ...args); if (events.listenerCount('log-debug') === 0) { emitLog(text, ...args); } } function reset() { downloadPromises = {all: {}}; languages.forEach(locale => { downloadPromises[locale] = {}; }); downloadUpdatePromise = null; storageRoot = __dirname; cache.flushAll(); events.removeAllListeners(); } reset(); class DataDragonHelper { static get events() { return events; } static get eventIds() { return eventIds; } static get realmToLocaleMap() { return realmToLocaleMap; } static get languages() { return languages; } static get localesForRealms() { return localesUsedForRealms; } static get storageRoot() { return storageRoot; } static set storageRoot(pathSegmentsArrayOrPathString) { if (Array.isArray(pathSegmentsArrayOrPathString)) { storageRoot = path.resolve(...pathSegmentsArrayOrPathString); } else { storageRoot = path.resolve(pathSegmentsArrayOrPathString); } emitLogInfo('setting storageRoot to ' + storageRoot); console.log('setting storageRoot to ' + storageRoot); ensureDirectoryExistence(storageRoot); } static reset() { reset(); } static buildStoragePath({version, locale}) { if (!version) { return emitError(new Error('buildStoragePath: no version provided')); } if (!locale) { return emitError(new Error('buildStoragePath: no locale provided')); } return path.resolve(DataDragonHelper.storageRoot, version, locale); } static get URL_DDRAGON_CDN() { return 'http://ddragon.leagueoflegends.com/cdn'; } static get URL_CDRAGON_PERKS() { return 'http://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/v1/perks.json'; } // TODO: download and extract TAR?? // link: http://ddragon.leagueoflegends.com/cdn/dragontail-7.20.3.tgz static get URL_DDRAGON_VERSIONS() { return 'http://ddragon.leagueoflegends.com/api/versions.json'; } static get URL_DDRAGON_REALMS() { return 'http://ddragon.leagueoflegends.com/api/realms.json'; } static get URL_DDRAGON_LANGUAGES() { return 'http://ddragon.leagueoflegends.com/cdn/languages.json'; } static getTarballLink(version) { return DataDragonHelper.URL_DDRAGON_CDN + `/dragontail-${version}.tgz`; } static getPerkImageUrl(perkId) { // cdragon url return `http://stelar7.no/cdragon/latest/perks/${perkId}.png`; } static getDDragonRealmUrl(realm) { return `http://ddragon.leagueoflegends.com/realms/${realm}.json`; } static getDdragonImgUrl({type, version, name}) { return `${DataDragonHelper.URL_DDRAGON_CDN}/${version}/img/${type}/${name}`; } static gettingVersions() { return requestingCached(DataDragonHelper.URL_DDRAGON_VERSIONS, 'versions'); } static gettingRealms() { return requestingCached(DataDragonHelper.URL_DDRAGON_REALMS, 'realms'); } static gettingRealmInfo(realm) { return requestingCached(DataDragonHelper.getDDragonRealmUrl(realm), 'realm/' + realm); } static gettingLanguages() { return requestingCached(DataDragonHelper.URL_DDRAGON_LANGUAGES, 'languages'); } static downloadingStaticDataByVersion({version, locales} = {}) { locales = locales || ['en_US']; if (!version) { return emitError(new Error('downloadingStaticDataByVersion: version is invalid, received: ' + version)); } return Bluebird.map(locales, (locale) => { const isAlreadyLoaded = fs.existsSync(path.resolve(DataDragonHelper.buildStoragePath({ version, locale }))); if (isAlreadyLoaded) { return true; } else { return downloadingStaticDataFiles(locale, version).then(() => { console.log('New Static Data assets were downloaded for: ' + locale + ' ' + version); emitDownload(locale, version, DataDragonHelper.buildStoragePath({locale, version})); return {version, locale}; }); } }, {concurrency: 1}); } static downloadingStaticDataByLocale(locale, versions = [], minimumMajorVersion = 10) { const versionsToLoad = getMissingVersionsFromDownloads(versions, minimumMajorVersion, locale); if (versionsToLoad.length === 0) { return Bluebird.resolve([]); } return Bluebird.map(versionsToLoad, (version) => { if (downloadPromises.all[version]) { return downloadPromises.all[version]; } if (downloadPromises[locale][version]) { return downloadPromises[locale][version]; } return downloadingStaticDataFiles(locale, version) .then(() => { console.log('New Static Data assets were downloaded for: ' + locale + ' ' + version); emitDownload(locale, version, DataDragonHelper.buildStoragePath({locale, version})); return versionsToLoad; }) .finally(() => { delete downloadPromises[locale][version]; }); }, {concurrency: 1}); } /** * Downloads static data for given locale and ALL versions * */ static downloadingStaticData(locale) { // TODO(refactor): rename to updating or something if (downloadUpdatePromise !== null) { return downloadUpdatePromise; } downloadUpdatePromise = DataDragonHelper.gettingVersions() .then(versions => { return DataDragonHelper.downloadingStaticDataByLocale(locale, versions); }).catch((err) => { console.warn('Error while downloading static-data', err); emitErrorCritical(err); }).finally(() => { downloadUpdatePromise = null; }); return downloadUpdatePromise; } static gettingLatestVersionFromDownloads(locale) { // TODO(fix): latest from Downloads might not be downloaded for locale yet, or new version might be available // currently it's safest to regularly check for new versions and download the needed locales independently return DataDragonHelper.gettingVersionsFromDownloads().then(versions => { return versions.sort(MatchUtil.sortVersionsDescending); }).then(versionsDescending => { if (versionsDescending.length === 0) { emitError(new Error('no downloaded versions available')); throw new Error('no downloaded versions available'); } if (!locale) { return versionsDescending[0]; } else { return findingDownloadedVersionOfLocale(DataDragonHelper.storageRoot, versionsDescending, 0, locale); } }).then((ddV) => { emitLogDebug(`Latest ddv in downloads${locale ? ' for ' + locale : ''}:`, ddV); return ddV; }); } static gettingVersionsFromDownloads() { // TODO(feat): add locale support return new Bluebird((resolve, reject) => { fs.readdir(DataDragonHelper.storageRoot, (err, files) => { if (err) { return reject(err); } resolve(files.filter(filename => { return !filename.includes('.js'); })); }); }); } /** * @see https://developer.riotgames.com/api-methods/#lol-static-data-v3/GET_getItemList * @param ddV * @param locale * @return {ItemListDTO} */ static gettingItemList(ddV, locale) { return /** @type ItemListDTO */ gettingLocalList(ddV, 'item', locale); } /** * @see https://developer.riotgames.com/api-methods/#lol-static-data-v3/GET_getItemList * @param ddV * @param locale * @return {RunesReforgedPathDTO[]} */ static gettingReforgedRunesList(ddV, locale) { // TODO(refactor): rename to perks!? // make sure 8.1 is used for patches that have perks enabled, but no ddragon data available if (ddV) { if (ddV.indexOf('7.23') >= 0) { ddV = '8.1.1'; } } return gettingLocalList(ddV, 'runesReforged', locale).catch((err) => { emitLogError('Error in RiotAppiHelper.gettingRunesList()', {locale, ddV, err}); return emitError(err); }); } /** * Gets the 'championFull' file. * @param ddV * @param locale * @return {ChampionListDTO<ChampionFullDTO>} */ static gettingFullChampionsList(ddV, locale) { return /** @type ChampionListDTO<ChampionFullDTO>*/ gettingLocalList(ddV, 'championFull', locale); } /** * Gets all champions (summary file). * Use gettingFullChampionsList for the complete data. * Champion data contains: * - version * - id * - key * - name * - title * - blurb * - info * - image * - tags * - partype * - stats * * For additional data use {@see #gettingFullChampionsList()} * @param ddV * @param locale * @return {ChampionListDTO<ChampionDTO>} */ static gettingChampionsList(ddV, locale) { // TODO: remove platform and options?!) emitLogDebug('gettingChampionsList() for region %s', ddV, locale); return /** @type ChampionListDTO<ChampionDTO> */ Promise.all([ // TODO: implement more eficient method to add the keys to champion.json // just write them while downloading, or store them separately? gettingLocalList(ddV, 'champion', locale), gettingLocalList(ddV, 'championFull', locale) ]).then(([championsList, championsFullList]) => { championsList.keys = championsFullList.keys; return championsList; }).catch((err) => { emitLogError('Error in gettingChampionsList()', {ddV, locale, err}); return emitError(err); }); } /** * @param ddV * @param locale * @returns {SummonerSpellListDTO} */ static gettingSummonerSpellsList(ddV, locale) { // TODO: remove platform and options?! emitLogDebug('getAllSummonerSpells() for region %s', locale, ddV); return gettingLocalList(ddV, 'summoner', locale).catch((err) => { emitLogError('Error in getAllSummonerSpells()', {locale, ddV, err}); return emitError(err); }); } /** * * @param ddV * @param locale * @returns {MasteryListDTO} */ static gettingMasteryList(ddV, locale) { if (!ddV || ddV.indexOf('7.24.') >= 0) { ddV = '7.23.1'; // latest version with runes/masteries } return /** @type MasteryListDTO */ gettingLocalList(ddV, 'mastery', locale); } /** * * @param ddV * @param locale * @returns {RuneListDTO} */ static gettingRuneList(ddV, locale) { if (!ddV || ddV.indexOf('7.24.') >= 0) { ddV = '7.23.1'; // latest version with runes/masteries } return /** @type RuneListDTO */ gettingLocalList(ddV, 'rune', locale); } /** * * @param ddV * @param locale * @returns {ListDTO<ProfileIconDataDTO>} keys = id */ static gettingProfileiconList(ddV, locale) { return /** @type ListDTO<ProfileIconDataDTO> */ gettingLocalList(ddV, 'profileicon', locale); } /** * * @param ddV * @param locale * @returns {LanguageStringsListDTO} */ static gettingLanguageStrings(ddV, locale) { return /** @type LanguageStringsListDTO */ gettingLocalList(ddV, 'language', locale); } /** * This endpoint is only supported for patch version 5.5.3 and later. * Also, map data was not generated for patch versions 5.15.1, 5.16.1, and 5.17.1. * @returns {MapListDTO} * */ static gettingMaps(ddV, locale) { let versionPromise; if (ddV) { versionPromise = Bluebird.resolve(ddV); } else { versionPromise = DataDragonHelper.gettingLatestVersion(); } return versionPromise.then(version => { const [major, minor, patch] = version.match(/\d+/g).map(s => parseInt(s)); if (major < 5 || (major === 5 && (minor < 5 || (minor === 5 && patch < 3)))) { return emitError(new Error('gettingMaps can not be used with versions earlier than 5.5.3')); } if (major === 5 && (minor === 15 || minor === 16 || minor === 17)) { return emitError(new Error('gettingMaps can not be used for versions 5.15.1, 5.16.1 and 5.17.1')); } return /** @type MapListDTO */ gettingLocalList(version, 'map', locale); }); } static gettingLatestVersion() { return DataDragonHelper.gettingVersions().then(versions => versions.sort(MatchUtil.sortVersionsDescending)[0]); } static gettingTarballLink(version) { let versionPromise; if (!version) { versionPromise = DataDragonHelper.gettingLatestVersion(); } else { versionPromise = Promise.resolve(version); } return versionPromise.then(ddV => { return DataDragonHelper.getTarballLink(ddV); }); } } /** * Returns the list-object. *Generally, just the "data" object is returned (omitting 'version' and 'type'). * In case of 'mastery' however, the return value will consist of {data, tree}. // TODO: normalize to send the whole object as is? + fixing the runesReforged ones? * @param version * @param type {string} "champion" | "item" | "mastery" | "profileicon" | "rune" | "summoner" | "language" * @param locale language to get the data for */ function gettingLocalList(version, type, locale = 'en_US', skipLatestVersionFallback = false) { if (!type) { return emitError(new Error('gettingLocalList: type is invalid. Expecting string, received: ' + type)); } let versionPromise; if (version) { versionPromise = Bluebird.resolve(version); } else { versionPromise = DataDragonHelper.gettingLatestVersion(); } return versionPromise .then((ddV) => { // trying to download files if neccessary return DataDragonHelper .downloadingStaticDataByVersion({version: ddV, locales: [locale]}) .then(() => ddV); }) .then((ddV) => { return new Promise((resolve, reject) => { const filePath = path.join(DataDragonHelper.buildStoragePath({ version: ddV, locale }), '/', type + '.json'); fs.readFile(filePath, 'utf8', (err, fileContent) => { if (!err) { resolve(JSON.parse(fileContent)); } else { reject(err); } }); }); }) .catch(err => { if (skipLatestVersionFallback) { // file not found expected, anything else needs to be reported return emitError(err); } // try once more with the version before and if that // also fails, give up (propably won't work // with additional tries. return DataDragonHelper.gettingVersions().then((versions) => { // Every version, that is not in versions-array, // will default to the latest one // contained (i.e. index 0) const indexFallbackVersion = versions.indexOf(version) + 1; if (skipLatestVersionFallback) { throw new Error('Could not receive data for ' + locale + ' ' + type + 'version: ' + version + ' fallback: ' + versions[indexFallbackVersion]); } else { return gettingLocalList(versions[indexFallbackVersion], type, locale, true); } }); }); } function ensureDirectoryExistence(filePath) { const dirname = !!path.extname(filePath) ? path.dirname(filePath) : filePath; if (fs.existsSync(dirname)) { return true; } mkdirsSync(dirname); } function requestingCached(url, cacheKey) { return new Bluebird((resolve, reject) => { const cachedValue = cache.get(cacheKey); if (cachedValue) { resolve(cachedValue); } else { request.get(url, (err, httpResponse, body) => { if (err) { console.log(err); reject(err); } else { cache.set(cacheKey, JSON.parse(body)); resolve(JSON.parse(body)); } }); } }); } function writeJsonAndResolve(json, dest, resolve) { const content = JSON.stringify(json); ensureDirectoryExistence(dest); fs.writeFile(dest, content, 'utf8', () => { resolve(); }); return content; } function fixPropertiesKeyAndId(json) { Object.keys(json.data).forEach(dataKey => { const obj = json.data[dataKey]; if (typeof obj !== 'object') { return; } if (obj.key && parseInt(obj.key) >= 0) { // if the key is numerical, this means key and id needs to be switched. // safety measure in case they fix this at any point const id = parseInt(obj.key); const key = obj.id; json.data[dataKey].id = id; json.data[dataKey].key = key; } if (!obj.id && parseInt(dataKey) >= 0) { // data items ids are used as key on the object and they might not have an id property json.data[dataKey].id = parseInt(dataKey); } else { json.data[dataKey].id = parseInt(json.data[dataKey].id); } }); } function getMissingVersionsFromDownloads(versions, minimumMajorVersion, locale) { return versions.filter(version => { return parseInt(version) >= minimumMajorVersion; }).filter(version => { // version not already downloaded return !fs.existsSync(path.resolve(DataDragonHelper.buildStoragePath({version, locale}))); }); } function downloadingStaticDataFiles(locale, version) { if (!locale || !version) { emitError(new Error('locale or version is invalid, received locale: ' + locale + ' version: ' + version)); return Promise.reject(new Error('locale or version is invalid, received locale: ' + locale + ' version: ' + version)); } // NOTE: locale not relevant for // profileicon // if (downloadPromises[locale][version]) { return downloadPromises[locale][version]; } const baseUrl = `${DataDragonHelper.URL_DDRAGON_CDN}/${version}/data/${locale}`; const profileIconUri = `${baseUrl}/profileicon.json`; const championUri = `${baseUrl}/champion.json`; const championFullUri = `${baseUrl}/championFull.json`; const itemUri = `${baseUrl}/item.json`; const summonerUri = `${baseUrl}/summoner.json`; const languageUri = `${baseUrl}/language.json`; const mapUri = `${baseUrl}/map.json`; /** removed with 7.24 */ const runesUri = `${baseUrl}/rune.json`; const masteriesUri = `${baseUrl}/mastery.json`; /** added with 7.24 */ const runesReforgedUri = `${baseUrl}/runesReforged.json`; const uriArray = [ profileIconUri, championUri, championFullUri, itemUri, summonerUri, languageUri, mapUri ]; const urisExcludedFromIdKeySwitch = [ languageUri, runesReforgedUri ]; const [major, minor, patch] = version.match(/\d+/g).map(s => parseInt(s)); // account for removal of runes and masteries with 7.24 // 7.24 was the last version within 7.x.y // and the first with runes/masteries removed if (major >= 8 || (major === 7 && minor === 24)) { uriArray.push(runesReforgedUri); } else { uriArray.push(runesUri, masteriesUri); } const downloadPromisesTemp = uriArray.map(uri => { return new Bluebird((resolve, reject) => { const filename = uri.substr(uri.lastIndexOf('/') + 1); const dest = path.resolve(DataDragonHelper.buildStoragePath({ version, locale }) + '/' + filename); console.log('requestPath: ' + uri); console.log('downloadPath: ' + dest); request(uri, (err, httpResponse, body) => { if (err) { reject(err); return; } let json; try { json = JSON.parse(body); } catch (e) { console.error(e, locale, version, uri, dest); return reject(new Error('Error while parsing response for locale: ' + locale + ' version: ' + version + 'URI: ' + uri)); } if (!urisExcludedFromIdKeySwitch.includes(uri)) { fixPropertiesKeyAndId(json); return writeJsonAndResolve(json, dest, resolve); } else { // In Ddragon files before 8.9.1 perks desc and longDesc contains Client placeholders for values, // which we can replace from the cdragon files // for later versions, we don't need to do that // TODO: should we simply use cdragon files anyways? if (major !== 8 || minor >= 8) { // 8.<9.y return writeJsonAndResolve(json, dest, resolve); } request(DataDragonHelper.URL_CDRAGON_PERKS, (errRunesCdragon, httpResponseRunesCdragon, bodyRunesCdragon) => { if (errRunesCdragon) { reject(errRunesCdragon); return; } try { let jsonCdragon = JSON.parse(bodyRunesCdragon); json.forEach(item => { item.slots.forEach(slot => { slot.runes.forEach(rune => { const runeCDragon = jsonCdragon.find(item => { return item.id === rune.id; }); if (!runeCDragon) { const message = 'CDragon is missing rune: ' + rune.id + '\r\n' + 'Not updating description within ddragon version: ' + version; emitLogError(message); } else { rune.shortDesc = runeCDragon.shortDesc; rune.longDesc = runeCDragon.longDesc; } }); }); }); } catch (e) { e.msg = 'Error during Rune-description processing - ' + e.msg; emitLogError(e); } return writeJsonAndResolve(json, dest, resolve); }); } }); }); }); downloadPromises[locale][version] = Bluebird.all(downloadPromisesTemp).finally(() => { delete downloadPromises[locale][version]; }); return downloadPromises[locale][version]; } function findingDownloadedVersionOfLocale(rootPath, versionsDescending, i, locale) { return new Bluebird((resolve, reject) => { fs.readdir(path.join(rootPath, '/', versionsDescending[i]), (err, files) => { if (!err && files && files.includes(locale)) { return resolve(versionsDescending[i]); } i++; if (i === versionsDescending.length) { return reject(new Error('No downloaded version available for given locale: ' + locale)); } return resolve(findingDownloadedVersionOfLocale(rootPath, versionsDescending, i, locale)); }); }); } module.exports = DataDragonHelper;