UNPKG

enkanetwork

Version:

API wrapper for enka.network written on TypeScript which provides localization, caching and convenience

293 lines (292 loc) 14.5 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.AssetsUpdater = void 0; const promises_1 = __importDefault(require("node:fs/promises")); const node_path_1 = __importDefault(require("node:path")); // @ts-ignore: JSON IMPORT OUT OF BASE-DIR const config_json_1 = __importDefault(require("../../../assets/config.json")); const errors_1 = require("../../errors"); const constants_1 = require("./constants"); const validate_1 = require("./validate"); // The on-disk config is generated at runtime (gitignored), so widen its type to // cover fields the seed may not have written yet (e.g. lastCommitId). const config = config_json_1.default; // Tries the primary host first, then the mirror, so a single host outage doesn't // break updates. async function request(url) { const bases = [constants_1.BASE_URL, constants_1.BASE_URL_FALLBACK]; let lastError; for (let index = 0; index < bases.length; index++) { const base = bases[index]; try { const res = await fetch(base + url); if (!res.ok) throw new errors_1.AssetsUpdateError(url, res); return (await res.json()); } catch (error) { lastError = error; // Surface each failed host so a silently-frozen primary (or a dead // mirror) is visible instead of masked by the try/catch. const nextBase = bases[index + 1]; const reason = error instanceof errors_1.AssetsUpdateError ? `HTTP ${error.data?.status ?? "?"}` : error?.message ?? String(error); console.warn(`[enkanetwork] request to ${base + url} failed (${reason})${nextBase ? `; falling back to ${nextBase}` : ""}`); } } throw lastError; } // Upstream splits oversized TextMaps (e.g. RU, TH) into `TextMap{LANG}_0.json`, // `_1.json`, … Fetch the single file when it exists, otherwise merge all parts. async function fetchTextMap(lang) { try { return await request(`${constants_1.LOCALIZATION_BASE_URL + lang}.json`); } catch { const merged = {}; for (let part = 0;; part++) { try { Object.assign(merged, await request(`${constants_1.LOCALIZATION_BASE_URL + lang}_${part}.json`)); } catch { break; } } if (!Object.keys(merged).length) throw new errors_1.AssetsUpdateError(`${constants_1.LOCALIZATION_BASE_URL + lang}.json`, { status: 404, message: `No TextMap found for language "${lang}"`, }); return merged; } } class AssetsUpdater { languages; isFetching; dataManager; localizationManager; constructor({ languages = ["EN"], checkInterval = 30 * 60 * 1000, // 0.5 hour instant = false, force = false, }, dataManager, localizationManager) { if (!languages.length) throw new Error("The languages value must contain at least 1 language"); if (languages.filter((language) => !constants_1.localizationLanguages.includes(language)) .length) throw new Error(`Use only supported languages! (${constants_1.localizationLanguages.join(", ")})`); this.dataManager = dataManager; this.localizationManager = localizationManager; // This is done so that the languages are not repeated and are not requested again this.languages = [...new Set(languages)]; this.isFetching = false; if (instant) this.fetchAssets(force); if (checkInterval) setInterval(() => this.fetchAssets().catch((error) => console.error("[enkanetwork] assets update failed:", error)), checkInterval); } // function that checks the relevance of assets data and localization async fetchAssets(force = false) { if (this.isFetching) throw new Error("Content is already fetching!"); this.isFetching = true; // (3) Track the source's newest commit id instead of a wall-clock stamp: a // stored "now" can run ahead of the latest commit (or a switched data // source), silently wedging the guard forever. Commit ids can't drift. // Best-effort: if the commits API is unreachable, we simply can't detect a // change and fall through to the other signals below. let latestCommitId; try { const res = await fetch(`${constants_1.PROJECT_GITLAB_URL}?per_page=1`); const commits = (await res.json()); latestCommitId = commits[0]?.id; } catch { } const missingLanguages = this.languages.some((language) => !config.languages.includes(language)); // (1) Cold start or wiped assets: nothing is generated yet, so always // rebuild regardless of the commit guard. const assetsMissing = this.dataManager.characters.length === 0; // Only trust a change when the API actually answered; an unreachable host // stays best-effort ("assume unchanged"), as before. const commitChanged = latestCommitId !== undefined && latestCommitId !== config.lastCommitId; // (2) `force` overrides every up-to-date signal. if (!force && !assetsMissing && !missingLanguages && !commitChanged && config.languages.length) { this.isFetching = false; return; } const skillIds = []; const [charactersData, costumesData, constellationsData, skillsetsData, skillsData, weaponsData, namecardsData, reliquaryData, reliquarySetData, reliquaryAffixData, profilePicturesData,] = await Promise.all([ request(constants_1.CHARACTER_DATA_URL), request(constants_1.COSTUME_DATA_URL), request(constants_1.CONSTELLATION_DATA_URL), request(constants_1.SKILLSET_DATA_URL), request(constants_1.SKILL_DATA_URL), request(constants_1.WEAPON_DATA_URL), request(constants_1.NAMECARD_DATA_URL), request(constants_1.RELIQUARY_DATA_URL), request(constants_1.RELIQUARY_SET_DATA_URL), request(constants_1.RELIQUARY_AFFIX_DATA_URL), request(constants_1.PROFILE_PICTURE_DATA_URL), ]); const characters = charactersData .filter((character) => !constants_1.excludeCharacters.includes(character.id)) .map((character) => { const skillset = skillsetsData.find((x) => x.id === character.skillDepotId); if (!skillset) return null; const energyBurst = skillsData.find((x) => x.id === skillset?.energySkill); if (!energyBurst) return null; skillIds.push(...skillset.skills.concat(skillset?.energySkill).filter(Boolean)); return { id: character.id, skillDepotId: character.skillDepotId, nameTextMapHash: character.nameTextMapHash, iconName: character.iconName, sideIconName: character.sideIconName, gachaIcon: `UI_Gacha_AvatarImg_${character.iconName .split("_") .at(-1)}`, qualityStars: constants_1.qualityTypesStars[character.qualityType], element: constants_1.elements[energyBurst.costElemType], skills: skillset.skills.concat(skillset?.energySkill).filter(Boolean), talents: skillset.talents.filter(Boolean), }; }) .filter(Boolean); // Aether and Lumine problem for (const character of charactersData) { if (character.id !== 10000005 && character.id !== 10000007) continue; for (const skillDepotId of character.candSkillDepotIds) { const skillset = skillsetsData.find((x) => x.id === skillDepotId); if (!skillset) continue; const energyBurst = skillsData.find((x) => x.id === skillset?.energySkill); if (!energyBurst) continue; skillIds.push(...skillset.skills.filter(Boolean)); characters.push({ id: character.id, skillDepotId, nameTextMapHash: character.nameTextMapHash, iconName: character.iconName, sideIconName: character.sideIconName, gachaIcon: `UI_Gacha_AvatarImg_${character.iconName .split("_") .at(-1)}`, qualityStars: constants_1.qualityTypesStars[character.qualityType], element: constants_1.elements[energyBurst.costElemType], skills: skillset.skills.filter(Boolean), talents: skillset.talents.filter(Boolean), }); } } const skills = skillsData .filter((skill) => skillIds.includes(skill.id)) .map((skill) => ({ id: skill.id, nameTextMapHash: skill.nameTextMapHash, skillIcon: skill.skillIcon, proudSkillGroupId: skill.proudSkillGroupId, })); const constellations = constellationsData.map((constellation) => ({ id: constellation.talentId, nameTextMapHash: constellation.nameTextMapHash, icon: constellation.icon, })); const costumes = costumesData.map((costume) => ({ // id: Object.values(costume).at(0), id: costume.skinId, iconName: costume.sideIconName.replace("_Side", ""), sideIconName: costume.sideIconName, gachaIcon: `UI_Costume_${costume.sideIconName.split("_").at(-1)}`, nameTextMapHash: costume.nameTextMapHash, })); const namecards = namecardsData .filter((namecard) => namecard.materialType === "MATERIAL_NAMECARD") .map((namecard) => ({ id: namecard.id, nameTextMapHash: namecard.nameTextMapHash, icon: namecard.icon, navbar: namecard.picPath.at(0) || null, banner: namecard.picPath.at(1), })); const profilePictures = profilePicturesData .filter((profilePicture) => profilePicture.iconPath) .map((profilePicture) => ({ id: profilePicture.id, iconName: profilePicture.iconPath, nameTextMapHash: profilePicture.nameTextMapHash, })); // Validate the transformed records before persisting so upstream schema drift // fails loudly instead of writing broken JSON over the good data. const problems = [ ...(0, validate_1.validateAssets)("characters", characters), ...(0, validate_1.validateAssets)("skills", skills), ...(0, validate_1.validateAssets)("constellations", constellations), ...(0, validate_1.validateAssets)("costumes", costumes), ...(0, validate_1.validateAssets)("namecards", namecards), ...(0, validate_1.validateAssets)("profilePictures", profilePictures), ]; if (problems.length) { this.isFetching = false; throw new errors_1.AssetsValidationError(problems); } await Promise.all([ this.dataManager.writeCharacters(characters), this.dataManager.writeSkills(skills), this.dataManager.writeConstellations(constellations), this.dataManager.writeCostumes(costumes), this.dataManager.writeNamecards(namecards), this.dataManager.writeProfilePictures(profilePictures), ]); const reliquarySets = reliquarySetData .map((reliquarySet) => { const reliquaryAffix = reliquaryAffixData.find((x) => x.id === reliquarySet.equipAffixId && x.level === 1); return reliquaryAffix?.nameTextMapHash; }) // Why doesn't the typescript compiler understand that it won't be undefined anymore ??? // Issue - https://github.com/microsoft/TypeScript/issues/45097 .filter(Boolean); //lang imports const nameTextHashMaps = { characters: characters.map((character) => character?.nameTextMapHash), costumes: costumes.map((costume) => costume.nameTextMapHash), constellations: constellations.map((constellation) => constellation.nameTextMapHash), namecards: namecards.map((namecard) => namecard.nameTextMapHash), skills: skills.map((skill) => skill.nameTextMapHash), weapons: weaponsData.map((weapon) => weapon.nameTextMapHash), reliquary: reliquaryData.map((reliquary) => reliquary.nameTextMapHash), reliquarySets, profilePictures: profilePictures.map((profilePicture) => profilePicture.nameTextMapHash), }; const languagesData = await Promise.all(this.languages.map((lang) => fetchTextMap(lang))); for (const dataType in nameTextHashMaps) { const assetType = dataType; const nameTextHashes = nameTextHashMaps[assetType]; const localization = nameTextHashes.reduce((acc, nameTextHash) => { acc[String(nameTextHash)] = {}; this.languages.forEach((lang, index) => { // I refer to the languagesData by index since Promise.all guarantees the preservation of order acc[String(nameTextHash)][lang] = languagesData[index][nameTextHash] ?? "UNKNOWN"; }); return acc; }, {}); await this.localizationManager.write(assetType, localization); } this.isFetching = false; config.lastUpdate = new Date().toISOString(); // Keep the previous id if the commits API was unreachable this run. config.lastCommitId = latestCommitId ?? config.lastCommitId; config.languages = this.languages; await promises_1.default.writeFile(node_path_1.default.resolve(constants_1.ASSETS_PATH, "config.json"), JSON.stringify(config, null, 4)); } } exports.AssetsUpdater = AssetsUpdater;