feat(gachabase): full game database sync and automatic cdn image resolution
This commit is contained in:
1 parent
5944673e0e
commit
039609980e
4 files changed
+640
-1049
No files matched your search
+113
-114
@@ -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();
|
||||
})();
|
||||
Reference in new issue
Block a user