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('/'); }