- Snapshot bakes an abilities index (373: name, generation, effect, and the species that have it). Snapshot ~872 KB / 133 KB gzip. - Search gains an 'Abilities' tab — browse all 373 offline, filter/search by name or effect, sort A–Z / most-Pokemon / newest - #/ability/:id detail: effect + a grid of every Pokemon with the ability (all from the snapshot, no fetch) - Pokemon detail: the Abilities line is now tappable, linking to each ability - #/natures: the full 25-nature +10%/-10% table; linked from the Team view Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
301 lines
9.9 KiB
JavaScript
301 lines
9.9 KiB
JavaScript
#!/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 _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);
|
|
|
|
console.log(`Fetching ${ids.length} species (stats, typings, flags) …`);
|
|
const species = await mapLimit(ids, CONCURRENCY, async (id) => {
|
|
const [pk, sp] = await Promise.all([
|
|
api(`pokemon/${id}`),
|
|
api(`pokemon-species/${id}`),
|
|
]);
|
|
const s = Object.fromEntries(pk.stats.map((x) => [x.stat.name, x.base_stat]));
|
|
const stats = {
|
|
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,
|
|
};
|
|
return {
|
|
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,
|
|
};
|
|
});
|
|
|
|
// ---- 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 (resolve to TM/HM number on demand)
|
|
machines: (m.machines || []).map((x) => ({
|
|
vg: x.version_group.name,
|
|
id: idFromUrl(x.machine.url),
|
|
})),
|
|
};
|
|
});
|
|
moves.sort((a, b) => a.id - b.id);
|
|
|
|
// ---- 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);
|
|
});
|