feat(gachabase): full game database sync and automatic cdn image resolution

This commit is contained in:
CAtlas0784 committed 2026-10-17 10:44:14 +07:00
1 parent 5944673e0e
commit 039609980e
4 files changed
+640 -1049

No files matched your search

+113 -114
View File
@@ -1,13 +1,13 @@
#!/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.
* Automatically fetches the complete character and lightcone catalog from Gachabase,
* detects new/updated items, and downloads high-res images to assets/.
*
* Usage:
* node scripts/sync-gachabase.mjs # Syncs all characters & LCs, downloading missing assets
* 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';
@@ -19,9 +19,6 @@ 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, {
@@ -60,149 +57,151 @@ function downloadBinary(url, destPath) {
});
}
function cleanRichText(str) {
if (!str || typeof str !== 'string') return '';
return str
.replace(/<color=[^>]+>/gi, '')
.replace(/<\/color>/gi, '')
.replace(/<[^>]+>/g, '')
.replace(/\\n/g, '\n')
.trim();
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) {}
}
}
}
return null;
}
const GACHABASE_PATHS = {
1: 'Destruction',
2: 'Hunt',
3: 'Erudition',
4: 'Harmony',
5: 'Nihility',
6: 'Preservation',
7: 'Abundance',
8: 'Remembrance',
9: 'Elation'
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'
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}...`);
async function syncAllCatalog() {
console.log('====================================================');
console.log('🔄 Syncing full game database from Gachabase...');
console.log('====================================================');
const { status, body } = await fetchHttps(url);
if (status !== 200) {
console.error(`[Error] Failed to fetch ${type} ${id}: HTTP ${status}`);
return null;
}
// 1. Sync Characters
console.log('\n[1/2] Fetching characters catalog...');
const { body: charBody } = await fetchHttps('https://hsr.gachabase.net/characters?lang=en&branch=beta');
const charData = parseSvelteKitCatalog(charBody);
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;
}
let newAvatarImages = 0;
if (charData && Array.isArray(charData.entries)) {
console.log(`Found ${charData.entries.length} characters in Gachabase catalog.`);
const assets = charData.refs?.assets || {};
const avatarDir = path.join(ROOT_DIR, 'assets', 'avatars');
fs.mkdirSync(avatarDir, { recursive: true });
const fn = new Function('return (' + kitMatch[1] + ')');
const kitData = fn();
for (const entry of charData.entries) {
const aid = entry.id;
const dest = path.join(avatarDir, `${aid}.png`);
const exists = fs.existsSync(dest) && fs.statSync(dest).size > 1000;
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 (!exists) {
const hash = entry.assets?.square_icon_path_hash || entry.assets?.round_icon_path_hash;
const iconUrl = assets[hash]?.url;
if (iconUrl) {
process.stdout.write(` Downloading icon for [${entry.rarity}★] ${entry.name?.text || aid} (ID: ${aid})... `);
const ok = await downloadBinary(iconUrl, dest);
if (ok) {
console.log('OK');
newAvatarImages++;
} else {
console.log('FAILED');
}
}
}
}
}
if (!targetData || !targetData.dto) {
console.error(`[Error] DTO not found for ${type} ${id}`);
return null;
}
// 2. Sync Lightcones
console.log('\n[2/2] Fetching lightcones catalog...');
const { body: lcBody } = await fetchHttps('https://hsr.gachabase.net/lightcones?lang=en&branch=beta');
const lcData = parseSvelteKitCatalog(lcBody);
const dto = targetData.dto;
const refs = targetData.refs || {};
const assetsMap = refs.assets || {};
let newLcImages = 0;
if (lcData && Array.isArray(lcData.entries)) {
console.log(`Found ${lcData.entries.length} lightcones in Gachabase catalog.`);
const assets = lcData.refs?.assets || {};
const lcDir = path.join(ROOT_DIR, 'assets', 'lightcones');
fs.mkdirSync(lcDir, { recursive: true });
const resolveAsset = (hash) => {
if (!hash) return null;
return assetsMap[hash]?.url || `https://cdn.gachabase.net/hsr/assets/${hash}.png`;
};
for (const entry of lcData.entries) {
const lcid = entry.id;
const dest = path.join(lcDir, `${lcid}.png`);
const exists = fs.existsSync(dest) && fs.statSync(dest).size > 1000;
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'}`);
// Only auto-download 5-star or missing special lightcones
if (!exists && (entry.rarity === 5 || lcid === 23065)) {
const hash = entry.assets?.thumbnail_art_path_hash || entry.assets?.full_art_path_hash;
const iconUrl = assets[hash]?.url;
if (iconUrl) {
process.stdout.write(` Downloading art for [${entry.rarity}★] ${entry.name?.text || lcid} (ID: ${lcid})... `);
const ok = await downloadBinary(iconUrl, dest);
if (ok) {
console.log('OK');
newLcImages++;
} else {
console.log('FAILED');
}
}
}
}
}
return {
id: dto.id,
name,
rarity: dto.rarity,
path: pathName,
element: elementName,
dto
};
console.log('\n====================================================');
console.log(`✅ Gachabase Catalog Sync Completed!`);
console.log(` New Avatars downloaded: ${newAvatarImages}`);
console.log(` New Lightcones downloaded: ${newLcImages}`);
console.log('====================================================\n');
}
(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);
const aid = parseInt(args[charIdx + 1], 10);
console.log(`Syncing single character: ${aid}`);
const { body } = await fetchHttps(`https://hsr.gachabase.net/characters/${aid}/slug/beta?lang=en`);
// Download icon
const match = body.match(/https:\/\/cdn\.gachabase\.net\/hsr\/assets\/[a-f0-9]+\.png/);
if (match) {
const dest = path.join(ROOT_DIR, 'assets', 'avatars', `${aid}.png`);
await downloadBinary(match[0], dest);
console.log(`Saved icon to ${dest}`);
}
return;
}
if (lcIdx !== -1 && args[lcIdx + 1]) {
const lcid = parseInt(args[lcIdx + 1], 10);
await syncItem('lightcone', lcid);
console.log(`Syncing single lightcone: ${lcid}`);
const { body } = await fetchHttps(`https://hsr.gachabase.net/lightcones/${lcid}/slug/beta?lang=en`);
const match = body.match(/https:\/\/cdn\.gachabase\.net\/hsr\/assets\/[a-f0-9]+\.png/);
if (match) {
const dest = path.join(ROOT_DIR, 'assets', 'lightcones', `${lcid}.png`);
await downloadBinary(match[0], dest);
console.log(`Saved LC art to ${dest}`);
}
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');
// Default: Sync full catalog
await syncAllCatalog();
})();