#!/usr/bin/env node /** * Build a compact, offline-first data snapshot from PokéAPI. * * The app runs its list, search, sort, filters and game switching entirely * off this file — no network until you open a detail page. It carries, per * species: name, generation, current + past typings, the six base stats and * their total, height, weight, base experience, and species flags * (legendary / mythical / baby, capture rate, growth rate, colour, egg * groups, gender rate). Plus every regional Pokédex and every version group. * * Richer detail (abilities, moves, flavour text, evolution, encounters) 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 = 16; 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 FORM_LABELS = { mega: 'Mega', 'mega-x': 'Mega X', 'mega-y': 'Mega Y', gmax: 'Gigantamax', alola: 'Alolan', galar: 'Galarian', hisui: 'Hisuian', paldea: 'Paldean', primal: 'Primal', origin: 'Origin', altered: 'Altered', incarnate: 'Incarnate', therian: 'Therian', }; function formLabel(slug, speciesName) { const rest = slug.startsWith(`${speciesName}-`) ? slug.slice(speciesName.length + 1) : slug; return FORM_LABELS[rest] || pretty(rest); } function formCategory(slug, speciesName) { const rest = slug.startsWith(`${speciesName}-`) ? slug.slice(speciesName.length + 1) : slug; if (rest.startsWith('mega')) return 'mega'; if (rest === 'gmax') return 'gmax'; if (['alola', 'galar', 'hisui', 'paldea'].includes(rest)) return 'regional'; if (rest === 'primal') return 'primal'; return 'other'; } 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 < 5; 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; let done = 0; const total = items.length; const workers = Array.from({ length: Math.min(limit, total) }, async () => { while (i < total) { const idx = i++; out[idx] = await fn(items[idx], idx); if (++done % 200 === 0 || done === total) { process.stdout.write(`\r … ${done}/${total} species`); } } }); await Promise.all(workers); if (total) process.stdout.write('\n'); 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(); // ---- Type name list ------------------------------------------------ const typeIndex = await api('type?limit=100'); const types = typeIndex.results .map((r) => r.name) .filter((n) => !['unknown', 'shadow', 'stellar'].includes(n)); // ---- Generations (for display names / regions) ------------------ const genIndex = await api('generation?limit=50'); const generations = []; await mapLimit(genIndex.results, 8, 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, }); }); generations.sort((a, b) => a.id - b.id); // ---- Pokédexes ---------------------------------------------------- const dexIndex = await api('pokedex?limit=100'); const pokedexes = []; await mapLimit(dexIndex.results, 8, 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, 8, 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)); // ---- Species: the data-rich part ----------------------------- const speciesIndex = await api('pokemon-species?limit=100000'); const ids = speciesIndex.results.map((r) => idFromUrl(r.url)).sort((a, b) => a - b); const statMap = (pk) => { const s = Object.fromEntries(pk.stats.map((x) => [x.stat.name, x.base_stat])); return { hp: s.hp ?? 0, atk: s.attack ?? 0, def: s.defense ?? 0, spa: s['special-attack'] ?? 0, spd: s['special-defense'] ?? 0, spe: s.speed ?? 0, }; }; const bstOf = (st) => st.hp + st.atk + st.def + st.spa + st.spd + st.spe; const abilKey = (pk) => pk.abilities .map((a) => a.ability.name) .sort() .join(','); const statKey = (st) => `${st.hp}/${st.atk}/${st.def}/${st.spa}/${st.spd}/${st.spe}`; console.log(`Fetching ${ids.length} species (stats, typings, flags, forms) …`); const species = await mapLimit(ids, CONCURRENCY, async (id) => { const [pk, sp] = await Promise.all([api(`pokemon/${id}`), api(`pokemon-species/${id}`)]); const stats = statMap(pk); const baseTypes = pk.types .slice() .sort((a, b) => a.slot - b.slot) .map((t) => t.type.name); const baseAbil = abilKey(pk); const baseStatKey = statKey(stats); // Non-default varieties = forms (megas, G-max, regional, alt formes). const variantSlugs = (sp.varieties || []) .filter((v) => !v.is_default) .map((v) => v.pokemon.name); const forms = []; // Sprite-only variants (Pikachu caps, Alcremie decorations, Gastrodon // east/west, Unown letters, Vivillon patterns, Furfrou trims, seasonal // Deerling/Sawsbuck, Flabébé/Floette/Florges colours…). Share the base // Pokémon's typing, stats, abilities and learnset — only the art differs, // so they get a lightweight { slug, id, name, sprite } record and a // separate, compact picker in the UI. const cosmeticForms = []; for (const slug of variantSlugs) { let fp; try { fp = await api(`pokemon/${slug}`); } catch { continue; } const fstats = statMap(fp); const ftypes = fp.types .slice() .sort((a, b) => a.slot - b.slot) .map((t) => t.type.name); const cat = formCategory(slug, sp.name); const entry = { slug, id: fp.id, name: formLabel(slug, sp.name), category: cat, types: ftypes, stats: fstats, bst: bstOf(fstats), height: fp.height, weight: fp.weight, }; // Purely-cosmetic forms: same typing, stats AND abilities as the base, // and not a named category (mega/gmax/regional/primal). const cosmetic = cat === 'other' && ftypes.join() === baseTypes.join() && statKey(fstats) === baseStatKey && abilKey(fp) === baseAbil; if (cosmetic) { cosmeticForms.push({ slug, id: fp.id, name: formLabel(slug, sp.name), sprite: fp.sprites?.front_default || null, }); continue; } forms.push(entry); } // Cosmetic forms that live on /pokemon-form rather than as varieties // (Unown, Vivillon, Furfrou, Deerling, Sawsbuck, Flabébé line…). if ((pk.forms || []).length > 1) { const known = new Set([...forms, ...cosmeticForms].map((f) => f.slug)); for (const fref of pk.forms) { if (fref.name === pk.name || known.has(fref.name)) continue; let fm; try { fm = await api(fref.url); } catch { continue; } if (fm.is_default || fm.is_battle_only || fm.is_mega) continue; const rest = fm.name.startsWith(`${sp.name}-`) ? fm.name.slice(sp.name.length + 1) : fm.form_name || fm.name; cosmeticForms.push({ slug: fm.name, id: fm.id, name: pretty(rest || fm.form_name || fm.name), sprite: fm.sprites?.front_default || null, }); known.add(fm.name); } } return { forms, ...(cosmeticForms.length ? { cosmeticForms } : {}), id, name: sp.name, generation: idFromUrl(sp.generation.url), types: pk.types .slice() .sort((a, b) => a.slot - b.slot) .map((t) => t.type.name), pastTypes: (pk.past_types || []).map((p) => ({ gen: idFromUrl(p.generation.url), types: p.types .slice() .sort((a, b) => a.slot - b.slot) .map((t) => t.type.name), })), stats, bst: stats.hp + stats.atk + stats.def + stats.spa + stats.spd + stats.spe, height: pk.height, weight: pk.weight, baseExp: pk.base_experience ?? null, isLegendary: sp.is_legendary, isMythical: sp.is_mythical, isBaby: sp.is_baby, captureRate: sp.capture_rate, baseHappiness: sp.base_happiness, growthRate: sp.growth_rate?.name || null, color: sp.color?.name || null, eggGroups: sp.egg_groups.map((g) => g.name), genderRate: sp.gender_rate, // Species this evolves FROM (null for base forms) — lets the app // derive "fully evolved" client-side without fetching every // evolution chain: a species nothing points to via this field has no // further evolution. evolvesFromId: sp.evolves_from_species ? idFromUrl(sp.evolves_from_species.url) : null, }; }); // ---- Version exclusivity (from wild encounters) --------------- // For each game, the versions a species has wild encounters in. If that's // a non-empty strict subset of the game's versions, it's version // exclusive there. Wild-only — gift/trade/evolution exclusives are missed. const versionToVg = {}; for (const vg of versionGroups) for (const v of vg.versions) versionToVg[v] = vg.key; const vgVersionCount = Object.fromEntries( versionGroups.map((vg) => [vg.key, vg.versions.length]), ); console.log(`Fetching wild encounters for ${species.length} species …`); await mapLimit(species, CONCURRENCY, async (sp) => { let enc; try { enc = await api(`pokemon/${sp.id}/encounters`); } catch { return; } const byVg = {}; for (const area of enc) { for (const vd of area.version_details || []) { const vg = versionToVg[vd.version.name]; if (vg) (byVg[vg] ||= new Set()).add(vd.version.name); } } const excl = {}; for (const [vg, set] of Object.entries(byVg)) { if (set.size > 0 && set.size < (vgVersionCount[vg] || 1)) excl[vg] = [...set]; } if (Object.keys(excl).length) sp.exclusiveIn = excl; }); // ---- Moves index ------------------------------------------- const moveIndex = await api('move?limit=100000'); console.log(`Fetching ${moveIndex.results.length} moves …`); const moves = await mapLimit(moveIndex.results, CONCURRENCY, async (r) => { const m = await api(r.url); return { id: m.id, name: m.name, type: m.type?.name || 'normal', damageClass: m.damage_class?.name || null, power: m.power, accuracy: m.accuracy, pp: m.pp, priority: m.priority, generation: idFromUrl(m.generation.url), // version-group -> machine id; `tm` label filled in by the pass below machines: (m.machines || []).map((x) => ({ vg: x.version_group.name, id: idFromUrl(x.machine.url), })), }; }); moves.sort((a, b) => a.id - b.id); // ---- TM / HM / TR numbers --------------------------------------- // Resolve every machine id to its item name ("tm25" -> "TM25") so the // search UI can show and sort by TM number without ~2000 lazy lookups. const machineIds = [...new Set(moves.flatMap((m) => m.machines.map((x) => x.id)))]; console.log(`Fetching ${machineIds.length} TM/HM numbers …`); const tmById = new Map(); await mapLimit(machineIds, CONCURRENCY, async (mid) => { try { const mc = await api(`machine/${mid}`); const raw = mc.item?.name || ''; const mm = raw.match(/^([a-z]+)0*(\d+)$/i); tmById.set( mid, mm ? `${mm[1].toUpperCase()}${String(Number(mm[2])).padStart(2, '0')}` : raw.toUpperCase(), ); } catch { /* leave unresolved */ } }); for (const m of moves) { for (const x of m.machines) x.tm = tmById.get(x.id) || null; } // ---- Abilities index ------------------------------------------ const abilityIndex = await api('ability?limit=100000'); console.log(`Fetching ${abilityIndex.results.length} abilities …`); const abilities = await mapLimit(abilityIndex.results, CONCURRENCY, async (r) => { const a = await api(r.url); const en = (a.effect_entries || []).find((e) => e.language.name === 'en'); const flavour = [...(a.flavor_text_entries || [])] .reverse() .find((e) => e.language.name === 'en'); return { id: a.id, name: a.name, generation: idFromUrl(a.generation.url), isMainSeries: a.is_main_series, effect: (en && (en.short_effect || en.effect)) || (flavour && flavour.flavor_text) || '', pokemon: [ ...new Set(a.pokemon.map((x) => idFromUrl(x.pokemon.url)).filter((n) => n <= 100000)), ], }; }); abilities.sort((a, b) => a.id - b.id); // ---- Item categories (gives every item a category cheaply) ---- const catIndex = await api('item-category?limit=100'); const items = []; await mapLimit(catIndex.results, CONCURRENCY, async (c) => { const cat = await api(c.name.startsWith('http') ? c.name : `item-category/${c.name}`); for (const it of cat.items) { items.push({ id: idFromUrl(it.url), name: it.name, category: cat.name }); } }); items.sort((a, b) => a.id - b.id); // ---- Write ---------------------------------------------------- const snapshot = { meta: { generatedAt: new Date().toISOString(), source: BASE, counts: { species: species.length, moves: moves.length, items: items.length, abilities: abilities.length, pokedexes: pokedexes.length, versionGroups: versionGroups.length, }, }, types, generations, species, moves, items, abilities, pokedexes, versionGroups, }; await mkdir(dirname(OUT), { recursive: true }); const body = JSON.stringify(snapshot); await writeFile(OUT, body); console.log( `Wrote ${OUT} — ${species.length} species, ${pokedexes.length} pokédexes, ` + `${versionGroups.length} version groups (${(body.length / 1024).toFixed(0)} 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); });