Files

136 lines
5.1 KiB
JavaScript

import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
const ROOT = process.cwd();
const CATALOG_PATH = process.env.ROMHACK_CATALOG_PATH ?? path.join(ROOT, 'data', 'catalog.json');
const BANNER_DIR = path.join(ROOT, 'public', 'assets', 'banners');
const GENERATED_DIR = path.join(ROOT, 'src', 'generated');
const SCRAPE_LIB_DIR = process.env.ROMHACK_SCRAPE_LIB_DIR ?? path.join(ROOT, 'data', 'scrape_lib');
const FILE_BASE = 'https://romhacks-files.ginnoir.com';
const GUIDE_EXTS = new Set(['.html', '.htm', '.pdf', '.xlsx', '.xls', '.ods', '.csv', '.doc', '.docx', '.rtf', '.txt', '.md', '.zip', '.rar', '.7z']);
const DOC_EXTS = new Set(['.html', '.htm', '.pdf', '.xlsx', '.xls', '.ods', '.csv', '.doc', '.docx', '.rtf']);
const GAME_DATA_DIRS = new Set(['pbs', 'graphics', 'plugins', 'audio', 'data', 'fonts', 'scripts', 'optional', 'se', 'bgm', 'tilesets', 'sound', 'movies', 'backup']);
await mkdir(BANNER_DIR, { recursive: true });
await mkdir(GENERATED_DIR, { recursive: true });
const catalog = JSON.parse(await readFile(CATALOG_PATH, 'utf8'));
const bannerManifest = {};
const fileManifest = {};
for (const hack of catalog.hacks) {
const slug = slugifyStem(hack.stem);
if (hack.banner) {
const localPath = await downloadBanner(slug, hack.banner);
if (localPath) bannerManifest[slug] = localPath;
}
const generatedLinks = await generatedFileLinks(slug, hack.scrape_dir);
if (generatedLinks.length > 0) fileManifest[slug] = generatedLinks;
}
await writeFile(path.join(GENERATED_DIR, 'banner-manifest.json'), `${JSON.stringify(bannerManifest, null, 2)}\n`);
await writeFile(path.join(GENERATED_DIR, 'file-manifest.json'), `${JSON.stringify(fileManifest, null, 2)}\n`);
console.log(`prepared banners=${Object.keys(bannerManifest).length}/${catalog.hacks.filter((hack) => hack.banner).length}`);
console.log(`prepared file manifests=${Object.keys(fileManifest).length}`);
async function downloadBanner(slug, url) {
const ext = extensionFromUrl(url);
const filename = `${slug}${ext}`;
const outPath = path.join(BANNER_DIR, filename);
if (existsSync(outPath)) return `/assets/banners/${filename}`;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
if (!response.ok) {
console.warn(`banner skipped ${slug}: HTTP ${response.status}`);
return undefined;
}
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length < 128) {
console.warn(`banner skipped ${slug}: response too small`);
return undefined;
}
await writeFile(outPath, buffer);
return `/assets/banners/${filename}`;
} catch (error) {
console.warn(`banner skipped ${slug}: ${error.message}`);
return undefined;
}
}
async function generatedFileLinks(slug, scrapeDir) {
if (!scrapeDir) return [];
const handoffPath = path.join(SCRAPE_LIB_DIR, scrapeDir, 'handoff.json');
if (!existsSync(handoffPath)) return [];
try {
const handoff = JSON.parse(await readFile(handoffPath, 'utf8'));
const guides = pickGuides(handoff.guides ?? [], handoff.artifact);
return guides.slice(0, 30).map((rel) => ({
label: rel,
url: `${FILE_BASE}/${encodePath(scrapeDir)}/${encodePath(rel)}`,
kind: 'guide',
}));
} catch (error) {
console.warn(`file manifest skipped ${slug}: ${error.message}`);
return [];
}
}
function pickGuides(guides, artifact) {
const artifactName = String(artifact ?? '').toLowerCase();
return guides
.map((rel) => String(rel).replaceAll('\\', '/'))
.filter((rel) => {
const parts = rel.toLowerCase().split('/');
const basename = parts.at(-1) ?? '';
const ext = path.extname(basename);
const inGameData = parts.slice(0, -1).some((part) => GAME_DATA_DIRS.has(part));
if (!GUIDE_EXTS.has(ext)) return false;
if (basename === 'note.md' || basename === 'handoff.json' || basename === artifactName) return false;
if (DOC_EXTS.has(ext)) return !inGameData;
if (ext === '.txt' || ext === '.md') return !inGameData && parts.length <= 2;
return true;
})
.sort((a, b) => scoreGuide(a) - scoreGuide(b) || a.localeCompare(b));
}
function scoreGuide(rel) {
const ext = path.extname(rel).toLowerCase();
if (DOC_EXTS.has(ext)) return 0;
if (ext === '.txt' || ext === '.md') return 1;
return 2;
}
function slugifyStem(stem) {
return String(stem)
.replaceAll('ΓÇÖ', "'")
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-zA-Z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.toLowerCase();
}
function extensionFromUrl(url) {
try {
const ext = path.extname(new URL(url).pathname).toLowerCase();
if (['.jpg', '.jpeg', '.png', '.webp', '.gif'].includes(ext)) return ext;
} catch {
// fall through
}
return '.img';
}
function encodePath(rel) {
return String(rel)
.split('/')
.filter(Boolean)
.map((segment) => encodeURIComponent(segment).replaceAll('(', '%28').replaceAll(')', '%29'))
.join('/');
}