222 lines
7.0 KiB
JavaScript
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);
|
|
});
|