// 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) 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 , , , from text strings */ export function cleanRichText(str) { if (!str || typeof str !== 'string') return ''; return str .replace(/]+>/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(/]*>([\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://cdn.jsdelivr.net/gh/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 Full Portrait / Splash Art * Falls back through: * 0. jsDelivr StarRailRes character_portrait (2048x2048 splash) * 1. jsDelivr StarRailRes character_drawing (transparent full body art) * 2. Raw GitHub character_portrait * 3. Raw GitHub character_drawing * 4. Gachabase splash cache * 5. jsDelivr avatar icon (visual fallback rather than blank) */ export function handleAvatarPortraitError(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'); const portraitUrl = `https://cdn.jsdelivr.net/gh/Mar-7th/StarRailRes@master/image/character_portrait/${id}.png`; if (img.src !== portraitUrl) { img.src = portraitUrl; return; } } if (step <= 1) { img.setAttribute('data-err-step', '2'); const drawingUrl = `https://cdn.jsdelivr.net/gh/Mar-7th/StarRailRes@master/image/character_drawing/${id}.png`; if (img.src !== drawingUrl) { img.src = drawingUrl; return; } } if (step <= 2) { img.setAttribute('data-err-step', '3'); const rawPortrait = `https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/image/character_portrait/${id}.png`; if (img.src !== rawPortrait) { img.src = rawPortrait; return; } } if (step <= 3) { img.setAttribute('data-err-step', '4'); const gachaSplash = GACHABASE_IMAGE_CACHE.avatarSplash?.[id] || KNOWN_GACHABASE_ASSETS.avatarSplash?.[id]; if (gachaSplash && img.src !== gachaSplash) { img.src = gachaSplash; return; } } // Step 4+: Visual fallback to avatar icon rather than ever leaving the portrait black/blank img.setAttribute('data-err-step', '5'); const iconUrl = `https://cdn.jsdelivr.net/gh/Mar-7th/StarRailRes@master/icon/avatar/${id}.png`; if (img.src !== iconUrl) { img.src = iconUrl; img.style.objectFit = 'contain'; } } /** * Robust error handler for Avatar images * Falls back through: jsDelivr StarRailRes -> Raw GitHub -> 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://cdn.jsdelivr.net/gh/Mar-7th/StarRailRes@master/icon/avatar/${id}.png`; } else if (step === 1) { img.setAttribute('data-err-step', '2'); img.src = `https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/icon/avatar/${id}.png`; } else if (step === 2) { img.setAttribute('data-err-step', '3'); 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: jsDelivr StarRailRes -> Raw GitHub -> 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://cdn.jsdelivr.net/gh/Mar-7th/StarRailRes@master/image/light_cone_preview/${id}.png`; } else if (step === 1) { img.setAttribute('data-err-step', '2'); img.src = `https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/image/light_cone_preview/${id}.png`; } else if (step === 2) { img.setAttribute('data-err-step', '3'); 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'; } }