enkanetwork
Version:
API wrapper for enka.network written on TypeScript which provides localization, caching and convenience
293 lines (292 loc) • 14.5 kB
JavaScript
;
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;