208 lines
7.4 KiB
JavaScript
208 lines
7.4 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Gachabase CLI Sync Utility for GaC-srtool & GaC-hkrpg-go
|
|
* 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
|
|
*/
|
|
|
|
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, '..');
|
|
|
|
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 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'
|
|
};
|
|
|
|
const GACHABASE_ELEMENTS = {
|
|
1: 'Physical', 2: 'Fire', 4: 'Ice', 8: 'Thunder',
|
|
16: 'Wind', 32: 'Quantum', 64: 'Imaginary'
|
|
};
|
|
|
|
async function syncAllCatalog() {
|
|
console.log('====================================================');
|
|
console.log('🔄 Syncing full game database from Gachabase...');
|
|
console.log('====================================================');
|
|
|
|
// 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);
|
|
|
|
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 });
|
|
|
|
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;
|
|
|
|
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');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
|
|
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 });
|
|
|
|
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;
|
|
|
|
// 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');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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');
|
|
|
|
if (charIdx !== -1 && args[charIdx + 1]) {
|
|
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);
|
|
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;
|
|
}
|
|
|
|
// Default: Sync full catalog
|
|
await syncAllCatalog();
|
|
})();
|