Files
Gacy-Srtool/js/gachabase.js
T

459 lines
16 KiB
JavaScript

// Gachabase Data Fetcher, Catalog Sync & Image Engine for GaC-srtool
// Connects with hsr.gachabase.net to dynamically update characters, lightcones,
// and high-res assets whenever the game updates or new patches/betas drop.
export const GACHABASE_BASE_URL = 'https://hsr.gachabase.net';
export const GACHABASE_CDN_URL = 'https://cdn.gachabase.net';
// Pre-mapped known beta / newest assets from Gachabase CDN
export const KNOWN_GACHABASE_ASSETS = {
avatars: {
1501: 'https://cdn.gachabase.net/hsr/assets/09ee6e2618ac2a0dab2aec8d73b0bb01.png', // Sparxie
1502: 'https://cdn.gachabase.net/hsr/assets/8ca2530c42791593b72d54a850fce97e.png', // Yao Guang
1505: 'https://cdn.gachabase.net/hsr/assets/723a90e789fd354fb96b450a90c73e30.png', // Evanescia
1506: 'https://cdn.gachabase.net/hsr/assets/0cbfb275bfef439b16ba6ff9f848eb3a.png', // Silver Wolf Collab
1511: 'https://cdn.gachabase.net/hsr/assets/2be49dbbe7cfe8f77187dcfea51d9863.png', // Aeon ★ Aha
1513: 'https://cdn.gachabase.net/hsr/assets/2fdf5d4fe6eb998064438ffd373ce95f.png', // Aventurine • Waveflair
},
avatarSplash: {
1511: 'https://cdn.gachabase.net/hsr/assets/f8b5630e85022ab8b2761e4e3f614b42.png', // Aha Splash Art (2048x1914)
1513: 'https://cdn.gachabase.net/hsr/assets/65b90212f8623ad92243d739ea1fc9ec.png', // Waveflair Splash
1505: 'https://cdn.gachabase.net/hsr/assets/71cbe3943310cb233e8dbe6ef69caeb1.png', // Evanescia Splash
1502: 'https://cdn.gachabase.net/hsr/assets/8ca2530c42791593b72d54a850fce97e.png', // Yao Guang
1501: 'https://cdn.gachabase.net/hsr/assets/09ee6e2618ac2a0dab2aec8d73b0bb01.png', // Sparxie
},
lightcones: {
23065: 'https://cdn.gachabase.net/hsr/assets/500cfc18698932759756f6b3ba4db417.png', // Upon the First Echo of "Aha" Full Art
},
lightconeThumbs: {
23065: 'https://cdn.gachabase.net/hsr/assets/c794417a81e656bfdf1212202230f80f.png', // Aha LC Thumbnail
}
};
// Global in-memory image cache populated dynamically from Gachabase catalogs
export const GACHABASE_IMAGE_CACHE = {
avatars: { ...KNOWN_GACHABASE_ASSETS.avatars },
avatarSplash: { ...KNOWN_GACHABASE_ASSETS.avatarSplash },
lightcones: { ...KNOWN_GACHABASE_ASSETS.lightcones },
lightconeThumbs: { ...KNOWN_GACHABASE_ASSETS.lightconeThumbs }
};
// Path ID mappings according to HSR engine / Gachabase
export const GACHABASE_PATHS = {
1: 'Destruction',
2: 'Hunt',
3: 'Erudition',
4: 'Harmony',
5: 'Nihility',
6: 'Preservation',
7: 'Abundance',
8: 'Remembrance',
9: 'Elation'
};
// Element / Type ID mappings
export const GACHABASE_ELEMENTS = {
1: 'Physical',
2: 'Fire',
4: 'Ice',
8: 'Thunder', // Lightning
16: 'Wind',
32: 'Quantum',
64: 'Imaginary'
};
// Stat ID mappings
export const GACHABASE_STATS = {
1: 'HP',
2: 'ATK',
3: 'DEF',
21: 'HP%',
22: 'ATK%',
23: 'DEF%',
31: 'Physical DMG%',
32: 'Fire DMG%',
33: 'Ice DMG%',
34: 'Lightning DMG%',
35: 'Wind DMG%',
36: 'Quantum DMG%',
37: 'Imaginary DMG%',
41: 'Break Effect%',
42: 'Outgoing Healing Boost%',
43: 'Max Energy',
44: 'Energy Regeneration Rate%',
45: 'Effect Hit Rate%',
46: 'Effect RES%',
51: 'SPD',
52: 'CRIT Rate%',
53: 'CRIT DMG%'
};
/**
* Strips rich-text tags like <color=#f29e38ff>, </color>, <b>, </b> from text strings
*/
export function cleanRichText(str) {
if (!str || typeof str !== 'string') return '';
return str
.replace(/<color=[^>]+>/gi, '')
.replace(/<\/color>/gi, '')
.replace(/<[^>]+>/g, '')
.replace(/\\n/g, '\n')
.trim();
}
/**
* Fetches HTML from targetUrl via backend API proxy or direct fetch
*/
export async function fetchGachabaseHtml(targetUrl, apiUrl = 'http://localhost:21000') {
if (!targetUrl) throw new Error('Target URL is required for Gachabase fetch');
// 1. Try Backend Proxy
if (apiUrl) {
try {
const cleanApi = apiUrl.replace(/\/+$/, '');
const proxyUrl = `${cleanApi}/api/gachabase/fetch?url=${encodeURIComponent(targetUrl)}`;
const resp = await fetch(proxyUrl, { method: 'GET', headers: { 'Accept': 'application/json' } });
if (resp.ok) {
const json = await resp.json();
if (json && json.html) {
return { html: json.html, finalUrl: json.url || targetUrl };
}
}
} catch (err) {
console.warn('Backend Gachabase proxy unreachable, attempting direct fetch...', err);
}
}
// 2. Direct fetch fallback
const directResp = await fetch(targetUrl, {
method: 'GET',
headers: {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
}
});
if (!directResp.ok) {
throw new Error(`Direct fetch failed with status ${directResp.status}`);
}
const html = await directResp.text();
return { html, finalUrl: targetUrl };
}
/**
* Parses SvelteKit streamed data payload from Gachabase catalog listing pages
*/
export function parseSvelteKitCatalog(html) {
if (!html || typeof html !== 'string') return null;
const scripts = [...html.matchAll(/<script[^>]*>([\s\S]*?)<\/script>/gi)];
for (const s of scripts) {
const text = s[1].trim();
if (text.includes('resolve') && text.includes('entries')) {
const startIdx = text.indexOf('() => ');
if (startIdx !== -1) {
let code = text.substring(startIdx + 6).trim();
if (code.endsWith(');')) code = code.slice(0, -2);
else if (code.endsWith(')')) code = code.slice(0, -1);
try {
const fn = new Function('return (' + code + ')');
const res = fn();
return res[0]?.data;
} catch (e) {
console.warn('Failed to parse catalog script:', e);
}
}
}
}
return null;
}
/**
* Automatically synchronizes the full game database from Gachabase
* Pulls all characters and lightcones, adds missing ones into metadata,
* and caches all Gachabase CDN asset URLs for seamless rendering.
*/
export async function syncGachabaseDatabase({ apiUrl, branch = 'beta', lang = 'en', metadataContext }) {
const {
AVATAR_METADATA,
LIGHTCONE_METADATA,
SIGNATURE_LIGHTCONES,
idbSet,
idbGet
} = metadataContext;
const charactersUrl = `${GACHABASE_BASE_URL}/characters?lang=${lang}&branch=${branch}`;
const lightconesUrl = `${GACHABASE_BASE_URL}/lightcones?lang=${lang}&branch=${branch}`;
let newAvatarsCount = 0;
let newLcsCount = 0;
let totalAvatarsCount = 0;
let totalLcsCount = 0;
// 1. Fetch & Parse Characters Catalog
try {
const { html: charHtml } = await fetchGachabaseHtml(charactersUrl, apiUrl);
const charData = parseSvelteKitCatalog(charHtml);
if (charData && Array.isArray(charData.entries)) {
totalAvatarsCount = charData.entries.length;
const assetsMap = charData.refs?.assets || {};
for (const entry of charData.entries) {
const aid = entry.id;
const hash = entry.assets?.square_icon_path_hash || entry.assets?.round_icon_path_hash;
const iconUrl = assetsMap[hash]?.url || KNOWN_GACHABASE_ASSETS.avatars[aid] || `${GACHABASE_CDN_URL}/hsr/assets/${hash}.png`;
if (iconUrl) {
GACHABASE_IMAGE_CACHE.avatars[aid] = iconUrl;
}
// Register new / missing characters into AVATAR_METADATA
if (AVATAR_METADATA && !AVATAR_METADATA[aid]) {
const charName = entry.name?.text || entry.name || `Avatar ${aid}`;
const pathName = GACHABASE_PATHS[entry.path_id] || 'Unknown';
const elementName = GACHABASE_ELEMENTS[entry.type_id] || 'Unknown';
AVATAR_METADATA[aid] = {
names: {
th: charName,
en: charName,
'zh-CN': charName
},
path: pathName,
element: elementName,
rarity: entry.rarity || 5,
hasExRank: false,
isGachabaseSync: true,
images: {
icon: iconUrl,
square: iconUrl
}
};
newAvatarsCount++;
} else if (AVATAR_METADATA && AVATAR_METADATA[aid]) {
// Enrich existing metadata with Gachabase CDN image
AVATAR_METADATA[aid].images = AVATAR_METADATA[aid].images || {};
if (iconUrl) AVATAR_METADATA[aid].images.icon = iconUrl;
}
}
}
} catch (err) {
console.error('Failed to sync characters from Gachabase:', err);
throw new Error('ไม่สามารถซิงค์ตัวละครจาก Gachabase ได้: ' + err.message);
}
// 2. Fetch & Parse Lightcones Catalog
try {
const { html: lcHtml } = await fetchGachabaseHtml(lightconesUrl, apiUrl);
const lcData = parseSvelteKitCatalog(lcHtml);
if (lcData && Array.isArray(lcData.entries)) {
totalLcsCount = lcData.entries.length;
const assetsMap = lcData.refs?.assets || {};
for (const entry of lcData.entries) {
const lcid = entry.id;
const thumbHash = entry.assets?.thumbnail_art_path_hash || entry.assets?.small_icon_path_hash;
const fullArtHash = entry.assets?.full_art_path_hash || entry.assets?.big_icon_path_hash;
const thumbUrl = assetsMap[thumbHash]?.url || KNOWN_GACHABASE_ASSETS.lightconeThumbs[lcid];
const fullArtUrl = assetsMap[fullArtHash]?.url || KNOWN_GACHABASE_ASSETS.lightcones[lcid];
if (thumbUrl) GACHABASE_IMAGE_CACHE.lightconeThumbs[lcid] = thumbUrl;
if (fullArtUrl) GACHABASE_IMAGE_CACHE.lightcones[lcid] = fullArtUrl;
// Register new / missing lightcones into LIGHTCONE_METADATA
if (LIGHTCONE_METADATA && !LIGHTCONE_METADATA[lcid]) {
const lcName = entry.name?.text || entry.name || `Lightcone ${lcid}`;
const pathName = GACHABASE_PATHS[entry.path_id] || 'Unknown';
LIGHTCONE_METADATA[lcid] = {
names: {
th: lcName,
en: lcName,
'zh-CN': lcName
},
rarity: entry.rarity || 5,
path: pathName,
isGachabaseSync: true,
images: {
thumbnail: thumbUrl,
fullArt: fullArtUrl
}
};
newLcsCount++;
} else if (LIGHTCONE_METADATA && LIGHTCONE_METADATA[lcid]) {
LIGHTCONE_METADATA[lcid].images = LIGHTCONE_METADATA[lcid].images || {};
if (thumbUrl) LIGHTCONE_METADATA[lcid].images.thumbnail = thumbUrl;
if (fullArtUrl) LIGHTCONE_METADATA[lcid].images.fullArt = fullArtUrl;
}
}
}
} catch (err) {
console.error('Failed to sync lightcones from Gachabase:', err);
throw new Error('ไม่สามารถซิงค์กรวยแสงจาก Gachabase ได้: ' + err.message);
}
// 3. Save cache to IndexedDB
if (idbSet) {
try {
await idbSet('gac_gachabase_image_cache', GACHABASE_IMAGE_CACHE);
await idbSet('gac_gachabase_last_sync', {
timestamp: Date.now(),
newAvatarsCount,
newLcsCount,
totalAvatarsCount,
totalLcsCount
});
} catch (e) {
console.warn('Failed to cache Gachabase database to IndexedDB:', e);
}
}
return {
newAvatarsCount,
newLcsCount,
totalAvatarsCount,
totalLcsCount
};
}
/**
* Loads cached Gachabase images and metadata from IndexedDB
*/
export async function loadCachedGachabaseData(metadataContext = {}) {
const { idbGet, AVATAR_METADATA, LIGHTCONE_METADATA } = metadataContext;
if (!idbGet) return;
try {
const cachedImages = await idbGet('gac_gachabase_image_cache');
if (cachedImages) {
if (cachedImages.avatars) Object.assign(GACHABASE_IMAGE_CACHE.avatars, cachedImages.avatars);
if (cachedImages.avatarSplash) Object.assign(GACHABASE_IMAGE_CACHE.avatarSplash, cachedImages.avatarSplash);
if (cachedImages.lightcones) Object.assign(GACHABASE_IMAGE_CACHE.lightcones, cachedImages.lightcones);
if (cachedImages.lightconeThumbs) Object.assign(GACHABASE_IMAGE_CACHE.lightconeThumbs, cachedImages.lightconeThumbs);
}
// Attach cached image links to metadata
if (AVATAR_METADATA) {
for (const aid in GACHABASE_IMAGE_CACHE.avatars) {
if (AVATAR_METADATA[aid]) {
AVATAR_METADATA[aid].images = AVATAR_METADATA[aid].images || {};
AVATAR_METADATA[aid].images.icon = GACHABASE_IMAGE_CACHE.avatars[aid];
}
}
}
if (LIGHTCONE_METADATA) {
for (const lcid in GACHABASE_IMAGE_CACHE.lightcones) {
if (LIGHTCONE_METADATA[lcid]) {
LIGHTCONE_METADATA[lcid].images = LIGHTCONE_METADATA[lcid].images || {};
LIGHTCONE_METADATA[lcid].images.fullArt = GACHABASE_IMAGE_CACHE.lightcones[lcid];
LIGHTCONE_METADATA[lcid].images.thumbnail = GACHABASE_IMAGE_CACHE.lightconeThumbs[lcid];
}
}
}
} catch (err) {
console.warn('Failed to restore Gachabase cache from IndexedDB:', err);
}
}
/**
* Image resolver for avatars
* Prioritizes local asset, falls back to Gachabase CDN
*/
export function getAvatarIconUrl(avatarId) {
const id = parseInt(avatarId, 10);
if (GACHABASE_IMAGE_CACHE.avatars[id]) {
return GACHABASE_IMAGE_CACHE.avatars[id];
}
if (KNOWN_GACHABASE_ASSETS.avatars[id]) {
return KNOWN_GACHABASE_ASSETS.avatars[id];
}
return `./assets/avatars/${id}.png`;
}
/**
* Image resolver for lightcones
*/
export function getLightconeArtUrl(lcId) {
const id = parseInt(lcId, 10);
if (GACHABASE_IMAGE_CACHE.lightcones[id]) {
return GACHABASE_IMAGE_CACHE.lightcones[id];
}
if (KNOWN_GACHABASE_ASSETS.lightcones[id]) {
return KNOWN_GACHABASE_ASSETS.lightcones[id];
}
if (id === 23065) {
return './assets/lightcones/23065.png';
}
return `https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/image/light_cone_preview/${id}.png`;
}
/**
* Image resolver for lightcone thumbnails
*/
export function getLightconeThumbUrl(lcId) {
const id = parseInt(lcId, 10);
if (GACHABASE_IMAGE_CACHE.lightconeThumbs[id]) {
return GACHABASE_IMAGE_CACHE.lightconeThumbs[id];
}
if (KNOWN_GACHABASE_ASSETS.lightconeThumbs[id]) {
return KNOWN_GACHABASE_ASSETS.lightconeThumbs[id];
}
return getLightconeArtUrl(lcId);
}
/**
* Robust error handler for Avatar images
* Falls back through: Local Asset -> StarRailRes -> Gachabase CDN -> Letter Placeholder
*/
export function handleAvatarImgError(img, avatarId) {
if (!img) return;
const id = parseInt(avatarId, 10);
const step = parseInt(img.getAttribute('data-err-step') || '0', 10);
if (step === 0) {
img.setAttribute('data-err-step', '1');
img.src = `https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/icon/avatar/${id}.png`;
} else if (step === 1) {
img.setAttribute('data-err-step', '2');
const gachaUrl = GACHABASE_IMAGE_CACHE.avatars[id] || KNOWN_GACHABASE_ASSETS.avatars[id];
if (gachaUrl && img.src !== gachaUrl) {
img.src = gachaUrl;
} else {
img.style.display = 'none';
if (img.nextElementSibling && img.nextElementSibling.classList.contains('avatar-icon-placeholder')) {
img.nextElementSibling.style.display = 'flex';
}
}
} else {
img.style.display = 'none';
if (img.nextElementSibling && img.nextElementSibling.classList.contains('avatar-icon-placeholder')) {
img.nextElementSibling.style.display = 'flex';
}
}
}
/**
* Robust error handler for Lightcone images
* Falls back through: Local Asset -> StarRailRes -> Gachabase CDN -> Default Logo
*/
export function handleLcImgError(img, lcId) {
if (!img) return;
const id = parseInt(lcId, 10);
const step = parseInt(img.getAttribute('data-err-step') || '0', 10);
if (step === 0) {
img.setAttribute('data-err-step', '1');
img.src = `https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/image/light_cone_preview/${id}.png`;
} else if (step === 1) {
img.setAttribute('data-err-step', '2');
const gachaUrl = GACHABASE_IMAGE_CACHE.lightcones[id] || KNOWN_GACHABASE_ASSETS.lightcones[id] || GACHABASE_IMAGE_CACHE.lightconeThumbs[id] || KNOWN_GACHABASE_ASSETS.lightconeThumbs[id];
if (gachaUrl && img.src !== gachaUrl) {
img.src = gachaUrl;
} else {
img.src = './assets/logo.png';
}
} else {
img.src = './assets/logo.png';
}
}