dex/scripts/build-snapshot.mjs
chris cd2655011f Scaffold Pokedex PWA (Vite + Workbox)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 10:54:36 -04:00

222 lines
7.0 KiB
JavaScript

#!/usr/bin/env node
/**
* Build a compact, offline-first data snapshot from PokéAPI.
*
* The app needs three things to work fully offline before any lazy detail
* fetch: the species list (id, name, generation, types), the regional
* Pokédex lists (which species, in what order, with regional numbers), and
* the version groups (games) with their associated Pokédexes.
*
* Everything richer than that — stats, abilities, flavor text, evolution —
* is fetched on demand by the app and cached by the service worker.
*
* Output: src/data/snapshot.json (imported by src/data/snapshot.js)
* Usage: node scripts/build-snapshot.mjs [--force]
*/
import { writeFile, mkdir, stat } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT = resolve(__dirname, '../src/data/snapshot.json');
const BASE = 'https://pokeapi.co/api/v2';
const MAX_AGE_DAYS = 30;
const FORCE = process.argv.includes('--force');
const CONCURRENCY = 8;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
const pretty = (k) =>
k.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const _cache = new Map();
async function api(path) {
if (_cache.has(path)) return _cache.get(path);
const url = path.startsWith('http') ? path : `${BASE}/${path}`;
let lastErr;
for (let attempt = 0; attempt < 4; attempt++) {
try {
const res = await fetch(url);
if (res.ok) {
const json = await res.json();
_cache.set(path, json);
return json;
}
if (res.status === 404) throw new Error(`404 ${url}`);
lastErr = new Error(`${res.status} ${url}`);
} catch (err) {
lastErr = err;
}
await sleep(400 * (attempt + 1));
}
throw lastErr;
}
async function mapLimit(items, limit, fn) {
const out = new Array(items.length);
let i = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (i < items.length) {
const idx = i++;
out[idx] = await fn(items[idx], idx);
}
});
await Promise.all(workers);
return out;
}
async function isFresh() {
try {
const s = await stat(OUT);
return (Date.now() - s.mtimeMs) / 86_400_000 < MAX_AGE_DAYS;
} catch {
return false;
}
}
async function main() {
if (!FORCE && (await isFresh())) {
console.log(
`snapshot.json is fresh (< ${MAX_AGE_DAYS} days old). Use --force to rebuild.`,
);
return;
}
console.log('Building snapshot from PokéAPI …');
const t0 = Date.now();
// ---- Types: build pokemonName -> [type, ...] -----------------------------
const typeIndex = await api('type?limit=100');
const usableTypes = typeIndex.results
.map((r) => r.name)
.filter((n) => !['unknown', 'shadow', 'stellar'].includes(n));
const typesByPokemon = new Map();
await mapLimit(usableTypes, CONCURRENCY, async (t) => {
const data = await api(`type/${t}`);
for (const { slot, pokemon } of data.pokemon) {
const arr = typesByPokemon.get(pokemon.name) || [];
arr.push({ slot, type: t });
typesByPokemon.set(pokemon.name, arr);
}
});
const typesFor = (name) =>
(typesByPokemon.get(name) || [])
.sort((a, b) => a.slot - b.slot)
.map((x) => x.type);
const typesForSpecies = (name) => {
const direct = typesFor(name);
if (direct.length) return direct;
// Fall back to a default form variant, e.g. "deoxys" -> "deoxys-normal".
for (const key of typesByPokemon.keys()) {
if (key === name || key.startsWith(`${name}-`)) {
const t = typesFor(key);
if (t.length) return t;
}
}
return [];
};
// ---- Generations: species -> { name, generation } ----------------------
const genIndex = await api('generation?limit=50');
const generations = [];
const speciesMeta = new Map();
await mapLimit(genIndex.results, CONCURRENCY, async (g) => {
const data = await api(`generation/${g.name}`);
generations.push({
id: data.id,
name: pretty(data.main_region?.name || g.name),
region: data.main_region?.name || null,
});
for (const sp of data.pokemon_species) {
const id = idFromUrl(sp.url);
speciesMeta.set(id, { id, name: sp.name, generation: data.id });
}
});
generations.sort((a, b) => a.id - b.id);
const speciesIds = [...speciesMeta.keys()].sort((a, b) => a - b);
const species = speciesIds.map((id) => {
const m = speciesMeta.get(id);
return {
id,
name: m.name,
generation: m.generation,
types: typesForSpecies(m.name),
};
});
// ---- Pokédexes --------------------------------------------------------
const dexIndex = await api('pokedex?limit=100');
const pokedexes = [];
await mapLimit(dexIndex.results, CONCURRENCY, async (d) => {
const data = await api(`pokedex/${d.name}`);
pokedexes.push({
key: data.name,
name: pretty(data.name),
region: data.region?.name || null,
isMainSeries: data.is_main_series,
versionGroups: data.version_groups.map((v) => v.name),
entries: data.pokemon_entries
.map((e) => [idFromUrl(e.pokemon_species.url), e.entry_number])
.sort((a, b) => a[1] - b[1]),
});
});
pokedexes.sort((a, b) => a.key.localeCompare(b.key));
// ---- Version groups (the "games") -----------------------------------
const vgIndex = await api('version-group?limit=100');
const versionGroups = [];
await mapLimit(vgIndex.results, CONCURRENCY, async (v) => {
const data = await api(`version-group/${v.name}`);
versionGroups.push({
key: data.name,
name: pretty(data.name),
generation: idFromUrl(data.generation.url),
versions: data.versions.map((x) => x.name),
pokedexKeys: data.pokedexes.map((x) => x.name),
});
});
versionGroups.sort(
(a, b) => a.generation - b.generation || a.key.localeCompare(b.key),
);
// ---- Write ----------------------------------------------------------
const snapshot = {
meta: {
generatedAt: new Date().toISOString(),
source: BASE,
counts: {
species: species.length,
pokedexes: pokedexes.length,
versionGroups: versionGroups.length,
},
},
types: usableTypes,
generations,
species,
pokedexes,
versionGroups,
};
await mkdir(dirname(OUT), { recursive: true });
await writeFile(OUT, JSON.stringify(snapshot));
const kb = (JSON.stringify(snapshot).length / 1024).toFixed(0);
console.log(
`Wrote ${OUT}${species.length} species, ${pokedexes.length} pokédexes, ` +
`${versionGroups.length} version groups (${kb} KB) in ${(
(Date.now() - t0) /
1000
).toFixed(1)}s`,
);
}
main().catch((err) => {
console.error('\nSnapshot build failed:', err.message);
console.error(
'If you are offline, commit a previously generated src/data/snapshot.json ' +
'or retry when you have a connection.',
);
process.exit(1);
});