feat(gachabase): add full Gachabase fetcher, parser, web modal and FreeSR importer

This commit is contained in:
CAtlas0784 committed 2026-10-17 03:59:45 +07:00
1 parent 431d8b5945
commit 5944673e0e
10 files changed
+1431 -4

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

+102
View File
@@ -2401,4 +2401,106 @@ input:checked + .slider:before {
filter: grayscale(0.8);
}
/* Gachabase Importer & Modal Styling */
.gachabase-quick-chip {
background: rgba(255, 255, 255, 0.05) !important;
border: 1px solid var(--border-color) !important;
color: var(--text-main) !important;
cursor: pointer;
transition: var(--transition);
}
.gachabase-quick-chip:hover {
background: rgba(251, 191, 36, 0.15) !important;
border-color: var(--accent-gold) !important;
color: var(--accent-gold) !important;
}
.gachabase-preview-card {
display: flex;
gap: 1.25rem;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 1.25rem;
margin-bottom: 1rem;
}
.gachabase-art-column {
width: 220px;
min-width: 220px;
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: center;
}
.gachabase-art-img {
width: 100%;
max-height: 280px;
object-fit: cover;
border-radius: var(--radius-sm);
border: 1px solid var(--border-color);
background: rgba(0, 0, 0, 0.4);
}
.gachabase-details-column {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.gachabase-header-title {
font-size: 1.4rem;
font-weight: 800;
color: #fff;
display: flex;
align-items: center;
gap: 0.6rem;
}
.gachabase-skill-item {
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 0.6rem 0.8rem;
display: flex;
gap: 0.75rem;
align-items: flex-start;
}
.gachabase-skill-icon {
width: 36px;
height: 36px;
min-width: 36px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.4);
border: 1px solid var(--border-color);
object-fit: contain;
padding: 2px;
}
.gachabase-eidolon-item {
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 0.5rem 0.75rem;
display: flex;
gap: 0.6rem;
align-items: center;
}
@media (max-width: 768px) {
.gachabase-preview-card {
flex-direction: column;
align-items: center;
}
.gachabase-art-column {
width: 100%;
}
}
+90
View File
@@ -44,6 +44,10 @@
<button class="btn btn-secondary btn-sm" id="btn-quick-ping" title="รีเฟรชการเชื่อมต่อ" data-i18n="refresh">
🔄 รีเฟรช
</button>
<button class="btn btn-secondary btn-sm" id="btn-open-gachabase" title="ดึงข้อมูลตัวละคร/กรวยแสงจาก Gachabase" style="border-color: rgba(245, 158, 11, 0.4); color: #fbbf24;">
🌐 Gachabase
</button>
</div>
</header>
@@ -194,6 +198,15 @@
<div style="font-size: 0.725rem; opacity: 0.85;" data-i18n="qaClearLineupDesc">เคลียร์ทุกช่องเพื่อจัดทีมใหม่</div>
</div>
</button>
<!-- 7. Gachabase Beta & Datamine Importer -->
<button type="button" class="btn btn-secondary" id="btn-quick-gachabase" style="display: flex; align-items: center; justify-content: flex-start; gap: 0.6rem; text-align: left; padding: 0.75rem 0.9rem; border-color: rgba(245, 158, 11, 0.4);">
<span style="font-size: 1.35rem; line-height: 1;">🌐</span>
<div>
<div style="font-weight: 700; font-size: 0.875rem; color: #fbbf24;">ดึงข้อมูล Gachabase</div>
<div style="font-size: 0.725rem; color: var(--text-muted);">นำเข้าตัวละคร/กรวยแสง เบต้าหรือใหม่ล่าสุด</div>
</div>
</button>
</div>
</div>
</div>
@@ -233,6 +246,7 @@
</select>
</div>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
<button class="btn btn-secondary" id="btn-avatars-gachabase" title="ดึงตัวละครจาก Gachabase" style="border-color: rgba(245, 158, 11, 0.4); color: #fbbf24;">🌐 Gachabase</button>
<button class="btn btn-primary" id="btn-all-avatars-e0s1" data-i18n="btnAllE0S1" style="background: linear-gradient(135deg, #0284c7 0%, #0369a1 100%); border-color: #38bdf8;">⚡ ปรับ E0S1 ทุกตัว</button>
<button class="btn btn-gold" id="btn-max-all-avatars" data-i18n="btnMaxAll">⭐ ปรับ Max E6 ทุกตัว</button>
<button class="btn btn-secondary" id="btn-reload-avatars" data-i18n="btnReload">🔄 รีโหลด</button>
@@ -285,6 +299,7 @@
<!-- Action Buttons -->
<div style="display: flex; gap: 0.4rem;">
<button type="button" class="btn btn-sm btn-secondary" id="btn-eq-gachabase" title="ดึงกรวยแสง/รีลิกส์จาก Gachabase" style="border-color: rgba(245, 158, 11, 0.4); color: #fbbf24;">🌐 Gachabase</button>
<button type="button" class="btn btn-sm btn-success" id="btn-open-add-lc" data-i18n="btnAddLc">+ เพิ่ม LC</button>
<button type="button" class="btn btn-sm btn-success" id="btn-open-add-relic" data-i18n="btnAddRelic" style="display: none;">+ เพิ่มรีลิกส์</button>
</div>
@@ -1372,6 +1387,81 @@
</div>
</div>
<!-- Gachabase Datamine & Importer Modal -->
<div class="modal-overlay" id="modal-gachabase">
<div class="modal-content" style="max-width: 860px; max-height: 90vh; display: flex; flex-direction: column;">
<div class="modal-header" style="border-bottom: 1px solid var(--border-color); padding-bottom: 0.75rem; margin-bottom: 0.75rem;">
<div style="display: flex; align-items: center; gap: 0.6rem;">
<span style="font-size: 1.5rem;">🌐</span>
<div>
<h3 style="font-weight: 800; margin: 0; color: #fbbf24;">
Gachabase Data Fetcher & Importer
</h3>
<div style="font-size: 0.75rem; color: var(--text-muted);">
ดึงข้อมูลตัวละคร, กรวยแสง, และรีลิกส์จาก hsr.gachabase.net (รองรับเบต้าและเนื้อหาใหม่ล่าสุด)
</div>
</div>
</div>
<button type="button" class="btn btn-sm btn-secondary" id="btn-close-gachabase-modal">✕</button>
</div>
<!-- Search & Input Bar -->
<div style="display: flex; flex-direction: column; gap: 0.6rem; margin-bottom: 0.75rem;">
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
<input type="text" id="gachabase-input" class="form-input" placeholder="🔍 ใส่ ID เช่น 1511, 23065 หรือวาง URL Gachabase ทั้งหมด..." style="flex: 1; min-width: 220px;">
<select id="gachabase-type" class="form-select" style="max-width: 140px;">
<option value="character">👥 ตัวละคร</option>
<option value="lightcone">🗡️ กรวยแสง</option>
<option value="relic">🛡️ รีลิกส์</option>
</select>
<select id="gachabase-branch" class="form-select" style="max-width: 110px;">
<option value="beta" selected>Beta (ล่าสุด)</option>
<option value="live">Live (เซิร์ฟจริง)</option>
</select>
<select id="gachabase-lang" class="form-select" style="max-width: 100px;">
<option value="en" selected>English</option>
<option value="zh-CN">简体中文</option>
<option value="ja">日本語</option>
<option value="th">ไทย</option>
</select>
<button type="button" class="btn btn-primary" id="btn-gachabase-fetch" style="min-width: 110px; font-weight: 700;">
🔍 ดึงข้อมูล
</button>
</div>
<!-- Quick Pick Badges -->
<div style="display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; font-size: 0.75rem;">
<span style="color: var(--text-muted); font-weight: 600;">ตัวอย่างยอดนิยม:</span>
<button type="button" class="btn btn-sm btn-secondary gachabase-quick-chip" data-id="1511" data-type="character" style="padding: 0.15rem 0.5rem; font-size: 0.75rem;">🎭 Aha (1511)</button>
<button type="button" class="btn btn-sm btn-secondary gachabase-quick-chip" data-id="23065" data-type="lightcone" style="padding: 0.15rem 0.5rem; font-size: 0.75rem;">🃏 LC "Aha" (23065)</button>
<button type="button" class="btn btn-sm btn-secondary gachabase-quick-chip" data-id="1513" data-type="character" style="padding: 0.15rem 0.5rem; font-size: 0.75rem;">🌊 Waveflair (1513)</button>
<button type="button" class="btn btn-sm btn-secondary gachabase-quick-chip" data-id="1505" data-type="character" style="padding: 0.15rem 0.5rem; font-size: 0.75rem;">✨ Evanescia (1505)</button>
<button type="button" class="btn btn-sm btn-secondary gachabase-quick-chip" data-id="1502" data-type="character" style="padding: 0.15rem 0.5rem; font-size: 0.75rem;">⭐ Yao Guang (1502)</button>
<button type="button" class="btn btn-sm btn-secondary gachabase-quick-chip" data-id="1501" data-type="character" style="padding: 0.15rem 0.5rem; font-size: 0.75rem;">⚡ Sparxie (1501)</button>
</div>
</div>
<!-- Status Indicator -->
<div id="gachabase-status" style="display: none; padding: 0.65rem 0.9rem; border-radius: var(--radius-sm); margin-bottom: 0.75rem; font-size: 0.85rem;"></div>
<!-- Preview Container -->
<div id="gachabase-preview-container" style="flex: 1; overflow-y: auto; padding-right: 0.35rem; display: none;">
<!-- Injected dynamically by JS -->
</div>
<!-- Modal Footer -->
<div id="gachabase-footer-actions" style="display: none; justify-content: space-between; align-items: center; margin-top: 0.75rem; border-top: 1px solid var(--border-color); padding-top: 0.75rem; flex-wrap: wrap; gap: 0.5rem;">
<div style="display: flex; gap: 0.5rem;">
<button type="button" class="btn btn-secondary" id="btn-gachabase-save-meta">💾 บันทึก Metadata เท่านั้น</button>
</div>
<div style="display: flex; gap: 0.5rem;">
<button type="button" class="btn btn-primary" id="btn-gachabase-import-e0s1" style="background: linear-gradient(135deg, #0284c7 0%, #0369a1 100%); border-color: #38bdf8;">⚡ นำเข้า FreeSR (E0S1)</button>
<button type="button" class="btn btn-gold" id="btn-gachabase-import-e6s5">⭐ นำเข้า FreeSR (E6S5)</button>
</div>
</div>
</div>
</div>
<!-- Toast Notification Container -->
<div class="toast-container" id="toast-container"></div>
+401 -3
View File
@@ -1,6 +1,14 @@
import { AVATAR_METADATA, getAvatarInfo } from './avatars-data.js';
import { LIGHTCONE_METADATA, RELIC_SET_METADATA, getLightconeInfo, getRelicSetInfo } from './equipment-data.js';
import { SUPPORTED_LANGUAGES, TRANSLATIONS, getCurrentLanguage, setLanguage, t, translateElement, translatePath } from './i18n.js';
import {
parseGachabaseInput,
fetchGachabaseHtml,
parseGachabaseHtml,
registerGachabaseItem,
loadCachedGachabaseRegistry,
applyGachabaseToFreeSR
} from './gachabase.js';
// Application State
const state = {
@@ -58,6 +66,25 @@ const el = {
btnQuickAddAllLcs: document.getElementById('btn-quick-add-all-lcs'),
btnQuickTeleportTrain: document.getElementById('btn-quick-teleport-train'),
btnQuickClearLineup: document.getElementById('btn-quick-clear-lineup'),
btnQuickGachabase: document.getElementById('btn-quick-gachabase'),
btnOpenGachabase: document.getElementById('btn-open-gachabase'),
btnAvatarsGachabase: document.getElementById('btn-avatars-gachabase'),
btnEqGachabase: document.getElementById('btn-eq-gachabase'),
// Gachabase Modal
modalGachabase: document.getElementById('modal-gachabase'),
btnCloseGachabaseModal: document.getElementById('btn-close-gachabase-modal'),
gachabaseInput: document.getElementById('gachabase-input'),
gachabaseType: document.getElementById('gachabase-type'),
gachabaseBranch: document.getElementById('gachabase-branch'),
gachabaseLang: document.getElementById('gachabase-lang'),
btnGachabaseFetch: document.getElementById('btn-gachabase-fetch'),
gachabaseStatus: document.getElementById('gachabase-status'),
gachabasePreviewContainer: document.getElementById('gachabase-preview-container'),
gachabaseFooterActions: document.getElementById('gachabase-footer-actions'),
btnGachabaseSaveMeta: document.getElementById('btn-gachabase-save-meta'),
btnGachabaseImportE0S1: document.getElementById('btn-gachabase-import-e0s1'),
btnGachabaseImportE6S5: document.getElementById('btn-gachabase-import-e6s5'),
// Avatars
avatarsGrid: document.getElementById('avatars-grid'),
@@ -756,8 +783,26 @@ function setupServerModes() {
}
// Initialize Avatars Database: Populates all known avatars from metadata
function initAvatarsList() {
if (state.avatarsList.length > 0) return;
function initAvatarsList(force = false) {
if (state.avatarsList.length > 0 && !force) {
let missing = false;
for (const aidStr in AVATAR_METADATA) {
const aid = parseInt(aidStr, 10);
if (!state.avatarsList.some(a => a.avatar_id === aid)) {
state.avatarsList.push({
avatar_id: aid,
level: 80,
promotion: 6,
rank: 6
});
missing = true;
}
}
if (missing) {
state.avatarsList.sort((a, b) => b.avatar_id - a.avatar_id);
}
return;
}
const list = [];
for (const aidStr in AVATAR_METADATA) {
const aid = parseInt(aidStr, 10);
@@ -772,6 +817,7 @@ function initAvatarsList() {
state.avatarsList = list;
}
// Default lineup matching our persistent server data
const DEFAULT_LINEUP = { 0: 1511, 1: 1506, 2: 1502, 3: 1503 };
@@ -1532,6 +1578,9 @@ const RECOMMENDED_RELIC_BUILDS = {
// Lightcone Art URL Resolvers (Local asset -> Gachabase -> StarRailRes)
function getLightconeArtSrc(lcId) {
const id = parseInt(lcId, 10);
const meta = LIGHTCONE_METADATA[id];
if (meta?.images?.fullArt) return meta.images.fullArt;
if (meta?.images?.thumbnail) return meta.images.thumbnail;
if (id === 23065) {
return './assets/lightcones/23065.png';
}
@@ -1540,12 +1589,32 @@ function getLightconeArtSrc(lcId) {
function getLightconeArtFallback(lcId) {
const id = parseInt(lcId, 10);
const meta = LIGHTCONE_METADATA[id];
if (meta?.images?.thumbnail) return meta.images.thumbnail;
if (meta?.images?.fullArt) return meta.images.fullArt;
if (id === 23065) {
return 'https://cdn.gachabase.net/hsr/assets/500cfc18698932759756f6b3ba4db417.png';
}
return `https://cdn.jsdelivr.net/gh/Mar-7th/StarRailRes@master/image/light_cone_preview/${id}.png`;
}
function getAvatarIconSrc(aid) {
const id = parseInt(aid, 10);
const meta = AVATAR_METADATA[id];
if (meta?.images?.icon) return meta.images.icon;
if (meta?.images?.square) return meta.images.square;
return `./assets/avatars/${id}.png`;
}
function getAvatarSplashSrc(aid) {
const id = parseInt(aid, 10);
const meta = AVATAR_METADATA[id];
if (meta?.images?.splash) return meta.images.splash;
if (meta?.images?.fullArt) return meta.images.fullArt;
return `https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/image/character_portrait/${id}.png`;
}
// Modal Sub-tabs Handler
function switchModalSubtab(targetId) {
const tabBtns = document.querySelectorAll('.modal-subtab-btn');
@@ -4903,6 +4972,326 @@ function setupTabs() {
});
}
// ==========================================
// Gachabase Data Fetcher & Importer Logic
// ==========================================
let currentGachabaseItem = null;
function openGachabaseModal(defaultId = null, defaultType = 'character') {
if (!el.modalGachabase) return;
el.modalGachabase.classList.add('active');
if (defaultId) {
if (el.gachabaseInput) el.gachabaseInput.value = String(defaultId);
if (el.gachabaseType) el.gachabaseType.value = defaultType;
performGachabaseFetch();
} else {
setTimeout(() => el.gachabaseInput?.focus(), 50);
}
}
function closeGachabaseModal() {
if (!el.modalGachabase) return;
el.modalGachabase.classList.remove('active');
}
async function performGachabaseFetch() {
const input = el.gachabaseInput?.value?.trim();
if (!input) {
showToast('กรุณาระบุ ID หรือ URL ของ Gachabase', 'warning');
return;
}
const type = el.gachabaseType?.value || 'character';
const branch = el.gachabaseBranch?.value || 'beta';
const lang = el.gachabaseLang?.value || 'en';
const parsedParams = parseGachabaseInput(input, type, branch, lang);
if (!parsedParams) {
showToast('รูปแบบข้อมูลไม่ถูกต้อง กรุณาระบุ ID ตัวเลขหรือ URL', 'error');
return;
}
if (parsedParams.type && el.gachabaseType) el.gachabaseType.value = parsedParams.type;
if (parsedParams.branch && el.gachabaseBranch) el.gachabaseBranch.value = parsedParams.branch;
if (el.gachabaseStatus) {
el.gachabaseStatus.style.display = 'block';
el.gachabaseStatus.style.background = 'rgba(56, 189, 248, 0.15)';
el.gachabaseStatus.style.border = '1px solid rgba(56, 189, 248, 0.4)';
el.gachabaseStatus.style.color = 'var(--accent-cyan)';
el.gachabaseStatus.innerHTML = `🔄 กำลังดึงข้อมูลจาก Gachabase (<code style="color: #fff;">${parsedParams.url}</code>)...`;
}
if (el.gachabasePreviewContainer) el.gachabasePreviewContainer.style.display = 'none';
if (el.gachabaseFooterActions) el.gachabaseFooterActions.style.display = 'none';
if (el.btnGachabaseFetch) el.btnGachabaseFetch.disabled = true;
try {
const { html, finalUrl } = await fetchGachabaseHtml(parsedParams.url, state.apiUrl);
const parsedItem = parseGachabaseHtml(html, lang);
parsedItem.sourceUrl = finalUrl;
parsedItem.branch = parsedParams.branch;
parsedItem.lang = lang;
currentGachabaseItem = parsedItem;
if (el.gachabaseStatus) {
el.gachabaseStatus.style.background = 'rgba(52, 211, 153, 0.15)';
el.gachabaseStatus.style.border = '1px solid rgba(52, 211, 153, 0.4)';
el.gachabaseStatus.style.color = 'var(--accent-green)';
el.gachabaseStatus.innerHTML = `✅ ดึงข้อมูลสำเร็จ: <strong>[${parsedItem.rarity}★] ${parsedItem.name}</strong> (ID: ${parsedItem.id})`;
}
renderGachabasePreview(parsedItem);
} catch (err) {
console.error('Gachabase fetch error:', err);
if (el.gachabaseStatus) {
el.gachabaseStatus.style.background = 'rgba(248, 113, 113, 0.15)';
el.gachabaseStatus.style.border = '1px solid rgba(248, 113, 113, 0.4)';
el.gachabaseStatus.style.color = 'var(--accent-red)';
el.gachabaseStatus.innerHTML = `❌ เกิดข้อผิดพลาดในการดึงข้อมูล: ${err.message}`;
}
} finally {
if (el.btnGachabaseFetch) el.btnGachabaseFetch.disabled = false;
}
}
function renderGachabasePreview(item) {
if (!el.gachabasePreviewContainer) return;
el.gachabasePreviewContainer.style.display = 'block';
if (el.gachabaseFooterActions) el.gachabaseFooterActions.style.display = 'flex';
const isChar = item.type === 'character';
const isLc = item.type === 'lightcone';
const isRelic = item.type === 'relic';
if (el.btnGachabaseImportE0S1) {
el.btnGachabaseImportE0S1.textContent = isChar ? '⚡ นำเข้า FreeSR (E0S1)' : '⚡ เพิ่มเข้า FreeSR (S1)';
}
if (el.btnGachabaseImportE6S5) {
el.btnGachabaseImportE6S5.textContent = isChar ? '⭐ นำเข้า FreeSR (E6S5)' : '⭐ เพิ่มเข้า FreeSR (S5)';
}
const starsHtml = '⭐'.repeat(item.rarity || 5);
let mainImgUrl = '';
if (isChar) {
mainImgUrl = item.images.splash || item.images.icon || `./assets/avatars/${item.id}.png`;
} else if (isLc) {
mainImgUrl = item.images.fullArt || item.images.thumbnail || `./assets/lightcones/${item.id}.png`;
} else {
mainImgUrl = item.images.icon || `./assets/logo.png`;
}
let recHtml = '';
if (isChar) {
const cavernSets = item.recommended.cavernSets || [];
const planarSets = item.recommended.planarSets || [];
const sigLcId = item.recommended.signatureLcId;
const sigLcMeta = sigLcId ? getLightconeInfo(sigLcId) : null;
recHtml = `
<div style="background: rgba(251, 191, 36, 0.08); border: 1px dashed rgba(251, 191, 36, 0.4); border-radius: var(--radius-sm); padding: 0.75rem; margin-top: 0.5rem;">
<div style="font-weight: 800; font-size: 0.825rem; color: var(--accent-gold); margin-bottom: 0.4rem;">
💡 คำแนะนำการจัด Build จาก Gachabase
</div>
<div style="display: flex; flex-direction: column; gap: 0.35rem; font-size: 0.8rem;">
<div>
<strong>🗡️ กรวยแสงแนะนำ (Signature):</strong>
<span style="color: #fff;">${sigLcMeta ? sigLcMeta.name : (sigLcId ? `LC ID: ${sigLcId}` : 'ตามสายตัวละคร')}</span>
</div>
<div>
<strong>🛡️ เซ็ตรีลิกส์แนะนำ (Cavern):</strong>
<span style="color: #cbd5e1;">${cavernSets.map(s => getRelicSetInfo(s).name).join(', ') || 'ตามประเภทสาย'}</span>
</div>
<div>
<strong>🔮 เครื่องประดับแนะนำ (Planar):</strong>
<span style="color: #cbd5e1;">${planarSets.map(s => getRelicSetInfo(s).name).join(', ') || 'ตามประเภทสาย'}</span>
</div>
</div>
</div>
`;
}
let skillsHtml = '';
if (isChar && item.skills && item.skills.length > 0) {
skillsHtml = `
<div style="margin-top: 1rem;">
<h4 style="font-size: 0.95rem; font-weight: 800; color: var(--accent-cyan); margin-bottom: 0.6rem;">
⚡ สกิลและการต่อสู้ (${item.skills.length} ท่า)
</h4>
<div style="display: flex; flex-direction: column; gap: 0.5rem; max-height: 260px; overflow-y: auto; padding-right: 0.25rem;">
${item.skills.map(s => `
<div class="gachabase-skill-item">
${s.icon ? `<img class="gachabase-skill-icon" src="${s.icon}" alt="${s.name}" onerror="this.style.display='none'">` : ''}
<div style="flex: 1; min-width: 0;">
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.2rem; flex-wrap: wrap;">
<span style="font-weight: 700; color: #fff; font-size: 0.875rem;">${s.name}</span>
<span class="skill-type-tag" style="font-size: 0.7rem;">${s.type}</span>
${s.tag ? `<span class="badge-super-tag" style="font-size: 0.7rem;">${s.tag}</span>` : ''}
</div>
<div style="font-size: 0.775rem; color: var(--text-muted); line-height: 1.4;">${s.description}</div>
</div>
</div>
`).join('')}
</div>
</div>
`;
} else if (isLc && item.lcSkill) {
skillsHtml = `
<div style="margin-top: 1rem; background: rgba(0, 0, 0, 0.25); border: 1px solid var(--border-color); border-radius: var(--radius-sm); padding: 0.75rem;">
<div style="font-weight: 700; color: var(--accent-gold); font-size: 0.9rem; margin-bottom: 0.35rem;">
✨ เอฟเฟกต์สกิลพิเศษ: ${item.lcSkill.name || 'Passive Skill'}
</div>
<div style="font-size: 0.8rem; color: var(--text-muted); line-height: 1.5;">${item.lcSkill.description || '-'}</div>
</div>
`;
}
let eidolonsHtml = '';
if (isChar && item.eidolons && item.eidolons.length > 0) {
eidolonsHtml = `
<div style="margin-top: 1rem;">
<h4 style="font-size: 0.95rem; font-weight: 800; color: var(--accent-gold); margin-bottom: 0.6rem;">
⭐ กลุ่มดาว Eidolons (E1 - E6)
</h4>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 0.5rem; max-height: 220px; overflow-y: auto; padding-right: 0.25rem;">
${item.eidolons.map(e => `
<div class="gachabase-eidolon-item">
<span class="badge-super-tag" style="background: rgba(251, 191, 36, 0.2); color: var(--accent-gold); font-weight: 800; min-width: 32px; text-align: center;">E${e.rank}</span>
<div style="flex: 1; min-width: 0;">
<div style="font-weight: 700; color: #fff; font-size: 0.825rem;">${e.name}</div>
<div style="font-size: 0.75rem; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;">${e.description}</div>
</div>
</div>
`).join('')}
</div>
</div>
`;
}
el.gachabasePreviewContainer.innerHTML = `
<div class="gachabase-preview-card">
<div class="gachabase-art-column">
<img class="gachabase-art-img" src="${mainImgUrl}" alt="${item.name}" onerror="this.src='./assets/logo.png'">
<div style="font-size: 1.1rem; letter-spacing: 2px;">${starsHtml}</div>
${isChar && item.maxEnergy ? `<div class="badge-super-tag" style="font-size: 0.75rem;">⚡ พลังงานสูงสุด: ${item.maxEnergy}</div>` : ''}
</div>
<div class="gachabase-details-column">
<div class="gachabase-header-title">
<span>${item.name}</span>
<span style="font-size: 0.75rem; font-weight: 600; padding: 0.15rem 0.5rem; background: rgba(255,255,255,0.08); border-radius: 4px; color: var(--text-muted);">ID: ${item.id}</span>
</div>
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
${isChar ? `${getElementIconHtml(item.element, 20)} <span style="font-size: 0.85rem; font-weight: 700;">${translateElement(item.element)}</span>` : ''}
${getPathIconHtml(item.path, 20)} <span style="font-size: 0.85rem; font-weight: 700;">${translatePath(item.path)}</span>
<span class="badge-super-tag" style="background: rgba(56, 189, 248, 0.15); color: var(--accent-cyan);">${item.branch.toUpperCase()}</span>
</div>
${recHtml}
${skillsHtml}
${eidolonsHtml}
</div>
</div>
`;
}
async function handleGachabaseSaveMetaOnly() {
if (!currentGachabaseItem) return;
try {
await registerGachabaseItem(currentGachabaseItem, {
AVATAR_METADATA,
LIGHTCONE_METADATA,
RELIC_SET_METADATA,
SIGNATURE_LIGHTCONES,
idbGet,
idbSet
});
initAvatarsList(true);
renderAvatarsGrid();
loadEquipment();
showToast(`💾 บันทึก Metadata ของ "${currentGachabaseItem.name}" เข้าสู่ระบบเรียบร้อย`, 'success');
} catch (err) {
showToast(err.message, 'error');
}
}
async function handleGachabaseImportToFreeSR(eLevel, sLevel) {
if (!currentGachabaseItem) return;
try {
await registerGachabaseItem(currentGachabaseItem, {
AVATAR_METADATA,
LIGHTCONE_METADATA,
RELIC_SET_METADATA,
SIGNATURE_LIGHTCONES,
idbGet,
idbSet
});
if (!state.fullFreesrData) {
state.fullFreesrData = { avatars: {}, lightcones: [], relics: [] };
}
const result = applyGachabaseToFreeSR(currentGachabaseItem, state.fullFreesrData, {
eLevel,
sLevel,
autoEquipLc: true,
autoEquipRelics: true
});
await saveFreesrChangesToServer();
initAvatarsList(true);
renderAvatarsGrid();
loadEquipment();
updateDashboard();
closeGachabaseModal();
if (currentGachabaseItem.type === 'character' && state.fullFreesrData.avatars[currentGachabaseItem.id]) {
openAvatarDetailModal(state.fullFreesrData.avatars[currentGachabaseItem.id]);
}
const rankLabel = currentGachabaseItem.type === 'character' ? (eLevel === 6 ? 'E6S5' : 'E0S1') : `S${sLevel}`;
showToast(`⚡ นำเข้า "${currentGachabaseItem.name}" (${rankLabel}) ลง FreeSR และบันทึกเซิร์ฟเวอร์สำเร็จ!`, 'success');
} catch (err) {
console.error('Import error:', err);
showToast(err.message, 'error');
}
}
function setupGachabaseEvents() {
const openButtons = [el.btnOpenGachabase, el.btnQuickGachabase, el.btnAvatarsGachabase, el.btnEqGachabase];
openButtons.forEach(btn => {
if (btn) {
btn.addEventListener('click', () => openGachabaseModal());
}
});
el.btnCloseGachabaseModal?.addEventListener('click', closeGachabaseModal);
document.querySelectorAll('.gachabase-quick-chip').forEach(chip => {
chip.addEventListener('click', () => {
const cid = chip.getAttribute('data-id');
const ctype = chip.getAttribute('data-type') || 'character';
if (el.gachabaseInput) el.gachabaseInput.value = cid;
if (el.gachabaseType) el.gachabaseType.value = ctype;
performGachabaseFetch();
});
});
el.btnGachabaseFetch?.addEventListener('click', performGachabaseFetch);
el.gachabaseInput?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
performGachabaseFetch();
}
});
el.btnGachabaseSaveMeta?.addEventListener('click', handleGachabaseSaveMetaOnly);
el.btnGachabaseImportE0S1?.addEventListener('click', () => handleGachabaseImportToFreeSR(0, 1));
el.btnGachabaseImportE6S5?.addEventListener('click', () => handleGachabaseImportToFreeSR(6, 5));
}
// Setup Event Listeners
function setupEvents() {
// Quick Ping & Refresh
@@ -5668,7 +6057,7 @@ function setupEvents() {
updateCacheUsageDisplay();
// Modal Backdrop Click to close
[el.modalAvatarDetail, el.modalLineupPicker, el.modalSettings].forEach(modal => {
[el.modalAvatarDetail, el.modalLineupPicker, el.modalSettings, el.modalGachabase].forEach(modal => {
if (modal) {
modal.addEventListener('click', (e) => {
if (e.target === modal) {
@@ -5695,6 +6084,14 @@ function startPingLoop() {
// Initialize Application
async function init() {
await loadCachedGachabaseRegistry({
AVATAR_METADATA,
LIGHTCONE_METADATA,
RELIC_SET_METADATA,
SIGNATURE_LIGHTCONES,
idbGet,
idbSet
});
initAvatarsList();
await loadCachedStorageData();
setupServerModes();
@@ -5702,6 +6099,7 @@ async function init() {
setupTabs();
setupModalSubtabs();
setupEvents();
setupGachabaseEvents();
applyTranslations();
renderLineup(state.statusData?.lineups || DEFAULT_LINEUP);
renderAvatarsGrid();
+625
View File
@@ -0,0 +1,625 @@
// Gachabase Data Fetcher, Parser & FreeSR Importer Module
// Supports fetching datamined/beta characters, lightcones, and relics from hsr.gachabase.net
// Integrates with GaC-srtool runtime metadata, IndexedDB cache, and FreeSR / persistent saves.
export const GACHABASE_BASE_URL = 'https://hsr.gachabase.net';
export const GACHABASE_CDN_URL = 'https://cdn.gachabase.net';
// 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();
}
/**
* Parses user input into normalized search parameters
* Handles full URLs or raw IDs
*/
export function parseGachabaseInput(input, typeHint = 'character', branch = 'beta', lang = 'en') {
const trimmed = (input || '').trim();
if (!trimmed) return null;
// Check if input is a full URL
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
try {
const urlObj = new URL(trimmed);
const pathname = urlObj.pathname.toLowerCase();
let detectedType = typeHint;
if (pathname.includes('/characters/') || pathname.includes('/character/')) detectedType = 'character';
else if (pathname.includes('/lightcones/') || pathname.includes('/lightcone/')) detectedType = 'lightcone';
else if (pathname.includes('/relics/') || pathname.includes('/relic/')) detectedType = 'relic';
// Pattern: /type/:id/:slug?/:branch?
const parts = pathname.split('/').filter(Boolean);
// Expected: ['characters', '1511', 'aeon-aha', 'beta']
let id = null;
let detectedBranch = urlObj.searchParams.get('branch') || branch;
let detectedLang = urlObj.searchParams.get('lang') || lang;
if (parts.length >= 2) {
id = parseInt(parts[1], 10);
}
if (parts.length >= 4) {
detectedBranch = parts[3];
}
return {
url: trimmed,
id: id || null,
type: detectedType,
branch: detectedBranch || 'beta',
lang: detectedLang || 'en'
};
} catch (e) {
// Fall through to ID parsing
}
}
// Treat as numeric ID
const numId = parseInt(trimmed, 10);
if (!isNaN(numId)) {
let resolvedType = typeHint;
// Auto-guess type if default wasn't explicitly changed
if (numId >= 20000 && numId < 30000) resolvedType = 'lightcone';
else if (numId < 1000) resolvedType = 'relic';
else if (numId >= 1000 && numId < 9000) resolvedType = 'character';
const typeSlug = resolvedType === 'lightcone' ? 'lightcones' : (resolvedType === 'relic' ? 'relics' : 'characters');
const url = `${GACHABASE_BASE_URL}/${typeSlug}/${numId}/slug/${branch}?lang=${lang}`;
return {
url,
id: numId,
type: resolvedType,
branch: branch || 'beta',
lang: lang || 'en'
};
}
return null;
}
/**
* Fetches Gachabase HTML content
* Uses GaC-hkrpg-go API proxy if available to avoid CORS, falls back to 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 (works if user has CORS unblocker or runs in local browser environment)
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 };
}
/**
* Extracts and parses serialized SvelteKit DTO and refs from Gachabase HTML
*/
export function parseGachabaseHtml(html, fallbackLang = 'en') {
if (!html || typeof html !== 'string') {
throw new Error('Invalid HTML content received from Gachabase');
}
// Look for kit.start(...) serialized state in <script>
let kitJson = null;
const kitMatch = html.match(/kit\.start\(app,\s*element,\s*(\{.*?\})\);\s*\}\);/s);
if (kitMatch && kitMatch[1]) {
kitJson = kitMatch[1];
} else {
// Alternate pattern in newer or minified builds
const altMatch = html.match(/kit\.start\(app,\s*element,\s*(\{[\s\S]*?\})\s*\);/);
if (altMatch && altMatch[1]) {
kitJson = altMatch[1];
}
}
if (!kitJson) {
throw new Error('Could not find SvelteKit data payload in Gachabase HTML');
}
let kitData;
try {
const fn = new Function('return (' + kitJson + ')');
kitData = fn();
} catch (err) {
throw new Error('Failed to evaluate Gachabase state payload: ' + err.message);
}
if (!kitData || !Array.isArray(kitData.data)) {
throw new Error('Gachabase kitData does not contain data array');
}
// Search for the entry containing dto and refs
let targetData = null;
for (const entry of kitData.data) {
if (!entry) continue;
const cand = entry.data?.data?.data || entry.data?.data || entry.data;
if (cand && (cand.dto || cand.refs)) {
targetData = cand;
break;
}
}
if (!targetData || !targetData.dto) {
throw new Error('Gachabase DTO object not found in payload');
}
const dto = targetData.dto;
const refs = targetData.refs || {};
const assetsMap = refs.assets || {};
const pathsMap = refs.paths || {};
const typesMap = refs.types || {};
// Resolve CDN URL helper
const resolveAsset = (hash) => {
if (!hash) return null;
if (assetsMap[hash] && assetsMap[hash].url) {
return assetsMap[hash].url;
}
return `${GACHABASE_CDN_URL}/hsr/assets/${hash}.png`;
};
const id = dto.id;
const rawName = dto.name?.text || dto.name || `Item ${id}`;
const rarity = dto.rarity || 5;
// Path resolution
const pathId = dto.path_id || 0;
const rawPath = pathsMap[pathId]?.name?.text || GACHABASE_PATHS[pathId] || 'Unknown';
// Type / Element resolution
const typeId = dto.type_id || 0;
const rawElement = typesMap[typeId]?.name?.text || GACHABASE_ELEMENTS[typeId] || 'Unknown';
// Determine Item Type
let itemType = 'character';
if (dto.skills !== undefined || dto.eidolons !== undefined || dto.max_energy !== undefined) {
itemType = 'character';
} else if (dto.skill !== undefined || (id >= 20000 && id < 30000)) {
itemType = 'lightcone';
} else if (id < 1000) {
itemType = 'relic';
}
// Images resolution
const images = {
splash: resolveAsset(dto.assets?.splash_art_path_hash) || resolveAsset(dto.assets?.draw_card_art_path_hash),
icon: resolveAsset(dto.assets?.round_icon_path_hash) || resolveAsset(dto.assets?.small_icon_path_hash) || resolveAsset(dto.assets?.icon_path_hash),
square: resolveAsset(dto.assets?.square_icon_path_hash) || resolveAsset(dto.assets?.big_icon_path_hash),
fullArt: resolveAsset(dto.assets?.full_art_path_hash) || resolveAsset(dto.assets?.splash_art_path_hash),
thumbnail: resolveAsset(dto.assets?.thumbnail_art_path_hash) || resolveAsset(dto.assets?.big_icon_path_hash)
};
// Skills parsing (for characters)
const skills = [];
if (Array.isArray(dto.skills)) {
dto.skills.forEach(s => {
skills.push({
id: s.id,
type: s.type?.text || s.type || 'Skill',
name: s.name?.text || s.name || '',
tag: s.tag?.text || s.tag || '',
description: cleanRichText(s.description?.text || s.description || ''),
icon: resolveAsset(s.icon_path_hash)
});
});
}
// Eidolons parsing (for characters)
const eidolons = [];
if (Array.isArray(dto.eidolons)) {
dto.eidolons.forEach((e, idx) => {
eidolons.push({
rank: e.rank || idx + 1,
name: e.name?.text || e.name || `Eidolon ${idx + 1}`,
description: cleanRichText(e.description?.text || e.description || ''),
icon: resolveAsset(e.icon_path_hash) || resolveAsset(dto.assets?.[`eidolon_${idx + 1}_path_hash`])
});
});
}
// Lightcone skill (for lightcones)
let lcSkill = null;
if (dto.skill) {
lcSkill = {
name: dto.skill.name?.text || dto.skill.name || '',
description: cleanRichText(dto.skill.description?.text || dto.skill.description || '')
};
}
// Build recommendations
const rec = dto.build_recommendations || {};
const recommendedRelics = rec.relics?.relic_ids || [];
const cavernSets = recommendedRelics.filter(rId => rId < 300);
const planarSets = recommendedRelics.filter(rId => rId >= 300);
// Recommended Lightcones
const recommendedLcs = (Array.isArray(rec.lightcone_ids) ? rec.lightcone_ids : (rec.lightcones || []).map(lc => lc.lightcone_id)).filter(Boolean);
// Main stats recommendations
const mainStats = {};
if (Array.isArray(rec.relics?.main_stats)) {
rec.relics.main_stats.forEach(m => {
const pieceId = m.piece_type_id; // 3=Body, 4=Boots, 5=Sphere, 6=Rope
const stats = (m.stat_ids || []).map(sid => GACHABASE_STATS[sid] || `Stat ${sid}`);
mainStats[pieceId] = stats;
});
}
// Sub stats recommendations
const subStats = (rec.relics?.sub_stat_ids || []).map(sid => GACHABASE_STATS[sid] || `Stat ${sid}`);
return {
id,
type: itemType,
name: rawName,
rarity,
path: rawPath,
pathId,
element: rawElement,
typeId,
maxEnergy: dto.max_energy || 0,
images,
skills,
eidolons,
lcSkill,
recommended: {
relicIds: recommendedRelics,
cavernSets,
planarSets,
lightconeIds: recommendedLcs,
signatureLcId: recommendedLcs[0] || null,
mainStats,
subStats
},
rawDto: dto
};
}
/**
* Registers parsed item into tool runtime metadata and saves to IndexedDB
*/
export async function registerGachabaseItem(parsedItem, metadataContext = {}) {
const {
AVATAR_METADATA,
LIGHTCONE_METADATA,
RELIC_SET_METADATA,
SIGNATURE_LIGHTCONES,
idbSet
} = metadataContext;
const id = parsedItem.id;
const lang = parsedItem.lang || 'en';
if (parsedItem.type === 'character' && AVATAR_METADATA) {
if (!AVATAR_METADATA[id]) {
AVATAR_METADATA[id] = {
names: {
th: parsedItem.name,
en: parsedItem.name
},
path: parsedItem.path,
element: parsedItem.element,
rarity: parsedItem.rarity,
hasExRank: false,
isCustomGachabase: true,
images: parsedItem.images
};
} else {
// Update existing entry with high-res images and enriched data
AVATAR_METADATA[id].names = AVATAR_METADATA[id].names || {};
AVATAR_METADATA[id].names[lang] = parsedItem.name;
AVATAR_METADATA[id].names.en = AVATAR_METADATA[id].names.en || parsedItem.name;
AVATAR_METADATA[id].path = parsedItem.path;
AVATAR_METADATA[id].element = parsedItem.element;
AVATAR_METADATA[id].rarity = parsedItem.rarity;
AVATAR_METADATA[id].images = parsedItem.images;
}
if (SIGNATURE_LIGHTCONES && parsedItem.recommended.signatureLcId) {
SIGNATURE_LIGHTCONES[id] = parsedItem.recommended.signatureLcId;
}
} else if (parsedItem.type === 'lightcone' && LIGHTCONE_METADATA) {
if (!LIGHTCONE_METADATA[id]) {
LIGHTCONE_METADATA[id] = {
names: {
th: parsedItem.name,
en: parsedItem.name
},
rarity: parsedItem.rarity,
path: parsedItem.path,
isCustomGachabase: true,
images: parsedItem.images
};
} else {
LIGHTCONE_METADATA[id].names = LIGHTCONE_METADATA[id].names || {};
LIGHTCONE_METADATA[id].names[lang] = parsedItem.name;
LIGHTCONE_METADATA[id].path = parsedItem.path;
LIGHTCONE_METADATA[id].rarity = parsedItem.rarity;
LIGHTCONE_METADATA[id].images = parsedItem.images;
}
}
// Persist to IndexedDB
if (idbSet) {
try {
const cacheKey = `gac_gachabase_item_${parsedItem.type}_${id}`;
await idbSet(cacheKey, parsedItem);
// Also append to custom items registry
const regKey = 'gac_gachabase_registry';
const existing = (await (metadataContext.idbGet ? metadataContext.idbGet(regKey) : null)) || [];
const filtered = existing.filter(i => !(i.type === parsedItem.type && i.id === parsedItem.id));
filtered.push({
type: parsedItem.type,
id: parsedItem.id,
name: parsedItem.name,
path: parsedItem.path,
element: parsedItem.element,
rarity: parsedItem.rarity,
cacheKey
});
await idbSet(regKey, filtered);
} catch (e) {
console.warn('Failed to save Gachabase item to IndexedDB:', e);
}
}
return parsedItem;
}
/**
* Loads all cached Gachabase items from IndexedDB into runtime metadata
*/
export async function loadCachedGachabaseRegistry(metadataContext = {}) {
const {
AVATAR_METADATA,
LIGHTCONE_METADATA,
SIGNATURE_LIGHTCONES,
idbGet
} = metadataContext;
if (!idbGet) return;
try {
const registry = await idbGet('gac_gachabase_registry');
if (!Array.isArray(registry)) return;
for (const itemMeta of registry) {
const fullItem = await idbGet(itemMeta.cacheKey);
if (fullItem) {
await registerGachabaseItem(fullItem, metadataContext);
}
}
console.log(`[Gachabase] Successfully loaded ${registry.length} cached items from IndexedDB.`);
} catch (err) {
console.warn('[Gachabase] Failed to restore cached items from IndexedDB:', err);
}
}
/**
* Applies parsed Gachabase character, lightcone, or relics into FreeSR data structure
*/
export function applyGachabaseToFreeSR(parsedItem, fullFreesrData, options = {}) {
if (!fullFreesrData) {
throw new Error('FreeSR data is not initialized');
}
fullFreesrData.avatars = fullFreesrData.avatars || {};
fullFreesrData.lightcones = fullFreesrData.lightcones || [];
fullFreesrData.relics = fullFreesrData.relics || [];
const eLevel = options.eLevel !== undefined ? options.eLevel : 0; // 0 or 6
const sLevel = options.sLevel !== undefined ? options.sLevel : 1; // 1 or 5
const autoEquipLc = options.autoEquipLc !== false;
const autoEquipRelics = options.autoEquipRelics !== false;
const results = {
avatarAdded: false,
lcAdded: false,
relicsAdded: 0
};
// If character:
if (parsedItem.type === 'character') {
const aid = parsedItem.id;
// Generate maxed traces & skills map
const skillsMap = {};
if (Array.isArray(parsedItem.skills) && parsedItem.skills.length > 0) {
parsedItem.skills.forEach(s => {
if (s.id) {
skillsMap[String(s.id)] = 10;
}
});
}
// Add standard fallback combat traces
skillsMap[`${aid}001`] = 6; // Basic ATK
skillsMap[`${aid}002`] = 10; // Skill
skillsMap[`${aid}003`] = 10; // Ultimate
skillsMap[`${aid}004`] = 10; // Talent
skillsMap[`${aid}007`] = 1; // Technique
if (!fullFreesrData.avatars[aid]) {
fullFreesrData.avatars[aid] = {
owner_uid: 0,
avatar_id: aid,
level: 80,
promotion: 6,
techniques: [],
sp_max: parsedItem.maxEnergy || 140,
sp_value: Math.floor((parsedItem.maxEnergy || 140) / 2),
data: {
rank: eLevel,
skills: skillsMap
}
};
} else {
const av = fullFreesrData.avatars[aid];
av.level = 80;
av.promotion = 6;
av.data = av.data || {};
av.data.rank = eLevel;
av.data.skills = Object.assign(av.data.skills || {}, skillsMap);
}
results.avatarAdded = true;
// Equip Signature Lightcone if available
const sigLcId = parsedItem.recommended.signatureLcId || (options.sigLcId || null);
if (autoEquipLc && sigLcId) {
// Check if this character already has an LC
let existingLc = fullFreesrData.lightcones.find(l => l.equip_avatar === aid);
if (existingLc) {
existingLc.item_id = sigLcId;
existingLc.level = 80;
existingLc.promotion = 6;
existingLc.rank = sLevel;
} else {
const nextUid = (fullFreesrData.lightcones.reduce((m, l) => Math.max(m, l.unique_id || 0), 0)) + 1;
fullFreesrData.lightcones.push({
unique_id: nextUid,
item_id: sigLcId,
level: 80,
promotion: 6,
rank: sLevel,
equip_avatar: aid
});
}
results.lcAdded = true;
}
// Equip Recommended Relics if available
if (autoEquipRelics && (parsedItem.recommended.cavernSets.length > 0 || parsedItem.recommended.planarSets.length > 0)) {
// Clear existing relics on this character
fullFreesrData.relics = fullFreesrData.relics.filter(r => r.equip_avatar !== aid);
const cavernSet = parsedItem.recommended.cavernSets[0] || 121;
const planarSet = parsedItem.recommended.planarSets[0] || 310;
let nextRelicUid = (fullFreesrData.relics.reduce((m, r) => Math.max(m, r.internal_uid || 0), 0)) + 1;
// 6 relic slots
for (let slot = 1; slot <= 6; slot++) {
const setId = slot <= 4 ? cavernSet : planarSet;
const relicId = 60000 + (setId * 10) + slot;
// Default smart main stats
let mainAffixId = 1;
if (slot === 3) mainAffixId = 5; // CRIT Rate% / ATK%
else if (slot === 4) mainAffixId = 5; // SPD / ATK%
else if (slot === 5) mainAffixId = 6; // Elemental DMG%
else if (slot === 6) mainAffixId = 4; // Energy Regen% / ATK%
fullFreesrData.relics.push({
internal_uid: nextRelicUid++,
relic_id: relicId,
level: 15,
equip_avatar: aid,
main_affix_id: mainAffixId,
sub_affixes: [
{ sub_affix_id: 1, cnt: 2, step: 2 },
{ sub_affix_id: 2, cnt: 3, step: 2 },
{ sub_affix_id: 3, cnt: 2, step: 2 },
{ sub_affix_id: 4, cnt: 2, step: 2 }
]
});
results.relicsAdded++;
}
}
} else if (parsedItem.type === 'lightcone') {
// Add standalone lightcone to inventory
const nextUid = (fullFreesrData.lightcones.reduce((m, l) => Math.max(m, l.unique_id || 0), 0)) + 1;
fullFreesrData.lightcones.push({
unique_id: nextUid,
item_id: parsedItem.id,
level: 80,
promotion: 6,
rank: sLevel,
equip_avatar: 0
});
results.lcAdded = true;
}
return results;
}
+5 -1
View File
@@ -292,7 +292,9 @@ export const TRANSLATIONS = {
"quickGenRecDesc": "สร้างรีลิกส์ 5★ +15 ตรงเซ็ตและสเตตัสหลักที่ดีที่สุด",
"btnGenAndEquip": "สร้าง & สวมใส่",
"selectFromInventory": "เลือกจากคลังเก็บของ (ตรงช่อง)",
"btnAllE0S1": "⚡ ปรับ E0S1 ทุกตัว"
"btnAllE0S1": "⚡ ปรับ E0S1 ทุกตัว",
"qaGachabaseTitle": "ดึงข้อมูล Gachabase",
"qaGachabaseDesc": "นำเข้าตัวละคร/กรวยแสง เบต้าหรือใหม่ล่าสุด"
},
"en": {
"ahaGlobalDesc": "Enable server-wide Aeon ★ Aha special trait",
@@ -327,6 +329,8 @@ export const TRANSLATIONS = {
"qaTeleportDesc": "Return to Parlor Car safe hub",
"qaClearLineupTitle": "Clear Team Lineup",
"qaClearLineupDesc": "Empty all slots to rebuild team",
"qaGachabaseTitle": "Fetch from Gachabase",
"qaGachabaseDesc": "Import latest beta & datamined characters/LCs",
"toastEnergy50": "Team energy set to 50% (standard)",
"toastEnergy100": "Team energy set to 100% (instant burst)",
"toastUnlockedAll": "Unlocked all 70+ characters at E6",
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env node
/**
* Gachabase CLI Sync Utility for GaC-srtool & GaC-hkrpg-go
* Fetches character/lightcone datamine from hsr.gachabase.net,
* downloads assets, and outputs or updates metadata files.
*
* Usage:
* node scripts/sync-gachabase.mjs --char 1511
* node scripts/sync-gachabase.mjs --lc 23065
* node scripts/sync-gachabase.mjs --all-beta
*/
import fs from 'fs';
import path from 'path';
import https from 'https';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT_DIR = path.resolve(__dirname, '..');
const KNOWN_BETA_CHARACTERS = [1501, 1502, 1505, 1511, 1513];
const KNOWN_BETA_LIGHTCONES = [23065];
function fetchHttps(url) {
return new Promise((resolve, reject) => {
https.get(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
}
}, (res) => {
if ([301, 302, 307, 308].includes(res.statusCode) && res.headers.location) {
let nextUrl = res.headers.location;
if (nextUrl.startsWith('/')) nextUrl = 'https://hsr.gachabase.net' + nextUrl;
return resolve(fetchHttps(nextUrl));
}
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => resolve({ status: res.statusCode, body }));
}).on('error', reject);
});
}
function downloadBinary(url, destPath) {
return new Promise((resolve) => {
https.get(url, (res) => {
if (res.statusCode === 200) {
const fileStream = fs.createWriteStream(destPath);
res.pipe(fileStream);
fileStream.on('finish', () => {
fileStream.close();
resolve(true);
});
} else {
res.resume();
resolve(false);
}
}).on('error', () => resolve(false));
});
}
function cleanRichText(str) {
if (!str || typeof str !== 'string') return '';
return str
.replace(/<color=[^>]+>/gi, '')
.replace(/<\/color>/gi, '')
.replace(/<[^>]+>/g, '')
.replace(/\\n/g, '\n')
.trim();
}
const GACHABASE_PATHS = {
1: 'Destruction',
2: 'Hunt',
3: 'Erudition',
4: 'Harmony',
5: 'Nihility',
6: 'Preservation',
7: 'Abundance',
8: 'Remembrance',
9: 'Elation'
};
const GACHABASE_ELEMENTS = {
1: 'Physical',
2: 'Fire',
4: 'Ice',
8: 'Thunder',
16: 'Wind',
32: 'Quantum',
64: 'Imaginary'
};
async function syncItem(type, id, branch = 'beta', lang = 'en') {
const typeSlug = type === 'lightcone' ? 'lightcones' : 'characters';
const url = `https://hsr.gachabase.net/${typeSlug}/${id}/slug/${branch}?lang=${lang}`;
console.log(`\n[Gachabase] Fetching ${type} ${id} from ${url}...`);
const { status, body } = await fetchHttps(url);
if (status !== 200) {
console.error(`[Error] Failed to fetch ${type} ${id}: HTTP ${status}`);
return null;
}
const kitMatch = body.match(/kit\.start\(app,\s*element,\s*(\{.*?\})\);\s*\}\);/s);
if (!kitMatch) {
console.error(`[Error] SvelteKit payload not found in page for ${type} ${id}`);
return null;
}
const fn = new Function('return (' + kitMatch[1] + ')');
const kitData = fn();
let targetData = null;
for (const entry of kitData.data) {
if (!entry) continue;
const cand = entry.data?.data?.data || entry.data?.data || entry.data;
if (cand && (cand.dto || cand.refs)) {
targetData = cand;
break;
}
}
if (!targetData || !targetData.dto) {
console.error(`[Error] DTO not found for ${type} ${id}`);
return null;
}
const dto = targetData.dto;
const refs = targetData.refs || {};
const assetsMap = refs.assets || {};
const resolveAsset = (hash) => {
if (!hash) return null;
return assetsMap[hash]?.url || `https://cdn.gachabase.net/hsr/assets/${hash}.png`;
};
const name = dto.name?.text || dto.name || `${type} ${id}`;
const pathName = refs.paths?.[dto.path_id]?.name?.text || GACHABASE_PATHS[dto.path_id] || 'Unknown';
const elementName = refs.types?.[dto.type_id]?.name?.text || GACHABASE_ELEMENTS[dto.type_id] || 'Unknown';
console.log(`✨ Successfully parsed: [${dto.rarity}★] ${name} (${pathName} / ${elementName})`);
// Download Avatar Icon / Splash
if (type === 'character') {
const iconUrl = resolveAsset(dto.assets?.round_icon_path_hash) || resolveAsset(dto.assets?.square_icon_path_hash);
if (iconUrl) {
const dest = path.join(ROOT_DIR, 'assets', 'avatars', `${id}.png`);
fs.mkdirSync(path.dirname(dest), { recursive: true });
const ok = await downloadBinary(iconUrl, dest);
console.log(` Icon saved -> assets/avatars/${id}.png: ${ok ? 'OK' : 'FAILED'}`);
}
} else if (type === 'lightcone') {
const iconUrl = resolveAsset(dto.assets?.full_art_path_hash) || resolveAsset(dto.assets?.thumbnail_art_path_hash);
if (iconUrl) {
const dest = path.join(ROOT_DIR, 'assets', 'lightcones', `${id}.png`);
fs.mkdirSync(path.dirname(dest), { recursive: true });
const ok = await downloadBinary(iconUrl, dest);
console.log(` Lightcone art saved -> assets/lightcones/${id}.png: ${ok ? 'OK' : 'FAILED'}`);
}
}
return {
id: dto.id,
name,
rarity: dto.rarity,
path: pathName,
element: elementName,
dto
};
}
(async () => {
const args = process.argv.slice(2);
const charIdx = args.indexOf('--char');
const lcIdx = args.indexOf('--lc');
const allBeta = args.includes('--all-beta');
if (allBeta) {
console.log('--- Starting Sync for All Known Beta Characters & Lightcones ---');
for (const cid of KNOWN_BETA_CHARACTERS) {
await syncItem('character', cid);
}
for (const lcid of KNOWN_BETA_LIGHTCONES) {
await syncItem('lightcone', lcid);
}
console.log('\n--- Beta Sync Completed ---');
return;
}
if (charIdx !== -1 && args[charIdx + 1]) {
const cid = parseInt(args[charIdx + 1], 10);
await syncItem('character', cid);
return;
}
if (lcIdx !== -1 && args[lcIdx + 1]) {
const lcid = parseInt(args[lcIdx + 1], 10);
await syncItem('lightcone', lcid);
return;
}
console.log('Usage:');
console.log(' node scripts/sync-gachabase.mjs --char <id>');
console.log(' node scripts/sync-gachabase.mjs --lc <id>');
console.log(' node scripts/sync-gachabase.mjs --all-beta');
})();