#!/usr/bin/env python3 """One-time script to fetch boss battle sprites from Bulbapedia archives. For trainer bosses (gym leaders, elite four, champions, kahunas): Downloads VS portraits or battle sprites from archives.bulbagarden.net. For totem/noble pokemon bosses: Links to existing pokemon sprites already in the project. Usage: python scripts/fetch_boss_sprites.py """ import json import re import sys import time import urllib.request from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SEED_DIR = ROOT / "backend" / "src" / "app" / "seeds" / "data" SPRITE_DIR = ROOT / "frontend" / "public" / "boss-sprites" BULBA_API = "https://archives.bulbagarden.net/w/api.php" USER_AGENT = "nuzlocke-tracker-sprite-fetch/1.0" # ── Game slug → Bulbapedia sprite naming conventions ── # For Gen 1-5: Spr_{CODE}_{Name}.png (pixel battle sprites) # For Gen 6+: VS{Name}.png or VS{Name}_{CODE}.png (VS portraits) GAME_SPRITE_CONFIG: dict[str, dict] = { "red": {"prefix": "Spr", "code": "RG", "fmt": "spr"}, "yellow": {"prefix": "Spr", "code": "Y", "fmt": "spr"}, "gold": {"prefix": "Spr", "code": "GS", "fmt": "spr"}, "crystal": {"prefix": "Spr", "code": "C", "fmt": "spr"}, "ruby": {"prefix": "Spr", "code": "RS", "fmt": "spr"}, "emerald": {"prefix": "Spr", "code": "E", "fmt": "spr"}, "firered": {"prefix": "Spr", "code": "FRLG", "fmt": "spr"}, "diamond": {"prefix": "Spr", "code": "DP", "fmt": "spr"}, "platinum": {"prefix": "Spr", "code": "Pt", "fmt": "spr"}, "heartgold": {"prefix": "Spr", "code": "HGSS", "fmt": "spr"}, "black": {"prefix": "Spr", "code": "BW", "fmt": "spr"}, "black-2": {"prefix": "Spr", "code": "B2W2", "fmt": "spr"}, "x": {"suffix": "", "fmt": "vs"}, "omega-ruby": {"suffix": "", "fmt": "vs"}, "sun": {"suffix": "", "fmt": "vs"}, "ultra-sun": {"suffix": "USUM", "fmt": "vs"}, "lets-go-pikachu": {"suffix": "PE", "fmt": "vs"}, "sword": {"suffix": "", "fmt": "vs"}, "brilliant-diamond": {"suffix": "BDSP", "fmt": "vs"}, "legends-arceus": {"suffix": "LA", "fmt": "vs"}, "scarlet": {"suffix": "", "fmt": "vs"}, } # ── Boss name → Bulbapedia filename overrides ── # For names that don't map cleanly to Bulbapedia filenames NAME_OVERRIDES: dict[str, str] = { "Lt. Surge": "Lt_Surge", "Crasher Wake": "Crasher_Wake", "Professor Kukui": "Professor_Kukui", "Tate & Lisa": "Tate_and_Liza", "Tate & Liza": "Tate_and_Liza", "Top Champion Geeta": "Geeta", } # ── Totem/Noble pokemon → pokemon name in seed data for sprite lookup ── TOTEM_POKEMON: dict[str, str] = { "Totem Gumshoos": "Gumshoos", "Totem Wishiwashi": "Wishiwashi Solo", "Totem Salazzle": "Salazzle", "Totem Lurantis": "Lurantis", "Totem Vikavolt": "Vikavolt", "Totem Mimikyu": "Mimikyu Disguised", "Totem Kommo-o": "Kommo O", "Totem Araquanid": "Araquanid", "Totem Togedemaru": "Togedemaru", "Totem Ribombee": "Ribombee", # Legends: Arceus nobles "Lord Kleavor": "Kleavor", "Lady Lilligant": "Lilligant (Hisui)", "Lord Arcanine": "Arcanine (Hisui)", "Lord Electrode": "Electrode (Hisui)", "Lord Avalugg": "Avalugg (Hisui)", "Origin Dialga / Palkia": "Dialga (Origin)", "Arceus": "Arceus", # Scarlet/Violet Titan pokemon "Stony Cliff Titan": "Klawf", "Open Sky Titan": "Bombirdier", "Lurking Steel Titan": "Orthworm", "Quaking Earth Titan": "Great Tusk", "False Dragon Titan": "Dondozo", } # Multi-boss entries: use the first trainer's name MULTI_BOSS_PRIMARY: dict[str, str] = { "Cilan / Chili / Cress": "Cilan", } def slugify(name: str) -> str: slug = name.lower().replace(" ", "-") return re.sub(r"[^a-z0-9-]", "", slug) def bulba_filename(boss_name: str, config: dict) -> str | None: """Construct the Bulbapedia filename for a trainer boss.""" name = MULTI_BOSS_PRIMARY.get(boss_name, boss_name) name = NAME_OVERRIDES.get(name, name) # Replace spaces with underscores for Bulbapedia bulba_name = name.replace(" ", "_") if config["fmt"] == "spr": return f"Spr_{config['code']}_{bulba_name}.png" else: suffix = config.get("suffix", "") if suffix: return f"VS{bulba_name}_{suffix}.png" else: return f"VS{bulba_name}.png" def resolve_image_urls(filenames: list[str]) -> dict[str, str | None]: """Batch-resolve Bulbapedia filenames to direct download URLs via the MediaWiki API.""" result: dict[str, str | None] = {} batch_size = 50 for i in range(0, len(filenames), batch_size): batch = filenames[i : i + batch_size] titles = "|".join(f"File:{fn}" for fn in batch) url = f"{BULBA_API}?action=query&titles={urllib.request.quote(titles)}&prop=imageinfo&iiprop=url&format=json" req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) try: with urllib.request.urlopen(req, timeout=30) as resp: data = json.loads(resp.read()) except (urllib.error.URLError, OSError) as exc: print(f" API error: {exc}") for fn in batch: result[fn] = None continue pages = data.get("query", {}).get("pages", {}) # Build title → url mapping page_urls: dict[str, str] = {} for page in pages.values(): info = page.get("imageinfo", []) if info: # Normalize title: "File:Spr BW Cilan.png" → "Spr_BW_Cilan.png" title = page["title"].removeprefix("File:").replace(" ", "_") page_urls[title] = info[0]["url"] for fn in batch: result[fn] = page_urls.get(fn) if i + batch_size < len(filenames): time.sleep(0.5) # be polite return result def download_image(url: str, dest: Path) -> bool: """Download an image file.""" dest.parent.mkdir(parents=True, exist_ok=True) req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) try: with urllib.request.urlopen(req, timeout=30) as resp: dest.write_bytes(resp.read()) return True except (urllib.error.URLError, OSError) as exc: print(f" FAILED to download {url}: {exc}") return False def load_pokemon_sprites() -> dict[str, str]: """Load pokemon name → sprite_url mapping from the pokemon seed data.""" pokemon_file = SEED_DIR / "pokemon.json" if not pokemon_file.exists(): return {} with open(pokemon_file) as f: pokemon_list = json.load(f) return {p["name"]: p.get("sprite_url", "") for p in pokemon_list if p.get("sprite_url")} def main(): pokemon_sprites = load_pokemon_sprites() if not pokemon_sprites: print("Warning: Could not load pokemon sprites for totem/noble mapping") # Collect all filenames we need to resolve all_filenames: list[str] = [] # Track: (game_slug, boss_index, bulba_filename, dest_path, is_pokemon) tasks: list[tuple[str, int, str | None, Path, bool]] = [] boss_files = sorted(SEED_DIR.glob("*-bosses.json")) for boss_file in boss_files: game_slug = boss_file.name.removesuffix("-bosses.json") config = GAME_SPRITE_CONFIG.get(game_slug) if config is None: print(f"Skipping {game_slug}: no sprite config defined") continue with open(boss_file) as f: bosses = json.load(f) sprite_subdir = SPRITE_DIR / game_slug for idx, boss in enumerate(bosses): boss_name = boss["name"] boss_slug = slugify(boss_name) dest = sprite_subdir / f"{boss_slug}.png" local_path = f"/boss-sprites/{game_slug}/{boss_slug}.png" # Check if sprite already exists on disk if dest.exists(): # Ensure seed file has the path if boss.get("sprite_url") != local_path: boss["sprite_url"] = local_path tasks.append((game_slug, idx, None, dest, False)) continue # Check if this is a totem/noble pokemon boss if boss_name in TOTEM_POKEMON: pokemon_name = TOTEM_POKEMON[boss_name] sprite = pokemon_sprites.get(pokemon_name) if sprite: boss["sprite_url"] = sprite tasks.append((game_slug, idx, None, dest, True)) continue else: print(f" Warning: No pokemon sprite found for {boss_name} ({pokemon_name})") # Construct Bulbapedia filename fn = bulba_filename(boss_name, config) if fn: all_filenames.append(fn) tasks.append((game_slug, idx, fn, dest, False)) else: print(f" Could not construct filename for {boss_name}") tasks.append((game_slug, idx, None, dest, False)) # Write back any sprite_url fixes with open(boss_file, "w") as f: json.dump(bosses, f, indent=2, ensure_ascii=False) f.write("\n") if not all_filenames: print("No sprites to download!") return # Deduplicate filenames (some bosses appear across games with same Bulba file) unique_filenames = list(dict.fromkeys(all_filenames)) print(f"\nResolving {len(unique_filenames)} unique Bulbapedia filenames...") url_map = resolve_image_urls(unique_filenames) resolved = sum(1 for v in url_map.values() if v) print(f"Resolved {resolved}/{len(unique_filenames)} URLs") # Report missing missing = [fn for fn, url in url_map.items() if not url] if missing: print(f"\nCould not resolve {len(missing)} filenames:") for fn in missing: print(f" - {fn}") # Download sprites and update seed files downloaded = 0 skipped = 0 failed = 0 # Re-read seed files for updating seed_data: dict[str, list] = {} for boss_file in boss_files: game_slug = boss_file.name.removesuffix("-bosses.json") with open(boss_file) as f: seed_data[game_slug] = json.load(f) for game_slug, idx, bulba_fn, dest, is_pokemon in tasks: if is_pokemon or dest.exists(): skipped += 1 continue if bulba_fn is None: continue url = url_map.get(bulba_fn) if not url: failed += 1 continue boss_slug = dest.stem local_path = f"/boss-sprites/{game_slug}/{boss_slug}.png" if download_image(url, dest): seed_data[game_slug][idx]["sprite_url"] = local_path downloaded += 1 print(f" {game_slug}/{boss_slug}.png") else: failed += 1 time.sleep(0.3) # rate limit # Write updated seed files for game_slug, bosses in seed_data.items(): boss_file = SEED_DIR / f"{game_slug}-bosses.json" with open(boss_file, "w") as f: json.dump(bosses, f, indent=2, ensure_ascii=False) f.write("\n") print(f"\nDone! Downloaded: {downloaded}, Skipped (existing): {skipped}, Failed: {failed}") # Fallback attempts for failed sprites if missing: print("\n--- Attempting fallback filenames for missing sprites ---") _attempt_fallbacks(missing, url_map, seed_data, tasks) def _attempt_fallbacks( missing_filenames: list[str], url_map: dict[str, str | None], seed_data: dict[str, list], tasks: list, ): """Try alternate Bulbapedia filename patterns for sprites that weren't found.""" fallback_filenames: list[str] = [] fallback_map: dict[str, str] = {} # fallback_fn → original_fn for orig_fn in missing_filenames: # Try VS ↔ Spr swaps if orig_fn.startswith("Spr_"): # Spr_CODE_Name.png → VSName.png parts = orig_fn.removeprefix("Spr_").removesuffix(".png").split("_", 1) if len(parts) == 2: name = parts[1] alt = f"VS{name}.png" fallback_filenames.append(alt) fallback_map[alt] = orig_fn elif orig_fn.startswith("VS"): # VSName.png → VSName_2.png (alternate appearance) base = orig_fn.removesuffix(".png") alt = f"{base}_2.png" fallback_filenames.append(alt) fallback_map[alt] = orig_fn if not fallback_filenames: return print(f"Trying {len(fallback_filenames)} fallback filenames...") fallback_urls = resolve_image_urls(fallback_filenames) found = 0 for fb_fn, fb_url in fallback_urls.items(): if fb_url: orig_fn = fallback_map[fb_fn] url_map[orig_fn] = fb_url found += 1 # Find and download for matching tasks for game_slug, idx, bulba_fn, dest, is_pokemon in tasks: if bulba_fn == orig_fn and not dest.exists(): boss_slug = dest.stem local_path = f"/boss-sprites/{game_slug}/{boss_slug}.png" if download_image(fb_url, dest): seed_data[game_slug][idx]["sprite_url"] = local_path print(f" {game_slug}/{boss_slug}.png (fallback: {fb_fn})") time.sleep(0.3) if found: # Re-save seed files for game_slug, bosses in seed_data.items(): boss_file = SEED_DIR / f"{game_slug}-bosses.json" with open(boss_file, "w") as f: json.dump(bosses, f, indent=2, ensure_ascii=False) f.write("\n") print(f"Fallback resolved: {found}/{len(fallback_filenames)}") if __name__ == "__main__": main()