feature/boss-sprites-and-badges (#22)
Reviewed-on: TheFurya/nuzlocke-tracker#22 Co-authored-by: Julian Tabel <juliantabel.jt@gmail.com> Co-committed-by: Julian Tabel <juliantabel.jt@gmail.com>
This commit was merged in pull request #22.
This commit is contained in:
146
scripts/fetch_badges.py
Normal file
146
scripts/fetch_badges.py
Normal file
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
"""One-time script to download badge images from Bulbapedia."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BADGES_DIR = Path(__file__).resolve().parent.parent / "frontend" / "public" / "badges"
|
||||
SEEDS_DIR = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "backend"
|
||||
/ "src"
|
||||
/ "app"
|
||||
/ "seeds"
|
||||
/ "data"
|
||||
)
|
||||
|
||||
MEDIAWIKI_API = "https://archives.bulbagarden.net/w/api.php"
|
||||
|
||||
|
||||
def get_referenced_badges() -> set[str]:
|
||||
"""Extract all unique non-null badge_image_url from seed files."""
|
||||
badges = set()
|
||||
for f in SEEDS_DIR.glob("*-bosses.json"):
|
||||
data = json.loads(f.read_text())
|
||||
for boss in data:
|
||||
url = boss.get("badge_image_url")
|
||||
if url:
|
||||
badges.add(url)
|
||||
return badges
|
||||
|
||||
|
||||
def get_missing_badges() -> list[str]:
|
||||
"""Return badge paths that are referenced but don't exist on disk."""
|
||||
referenced = get_referenced_badges()
|
||||
missing = []
|
||||
for badge_path in sorted(referenced):
|
||||
full_path = BADGES_DIR / Path(badge_path).name
|
||||
if not full_path.exists():
|
||||
missing.append(badge_path)
|
||||
return missing
|
||||
|
||||
|
||||
def badge_path_to_bulbapedia_filename(badge_path: str) -> str:
|
||||
"""Convert /badges/coal-badge.png -> Coal_Badge.png"""
|
||||
name = Path(badge_path).stem # e.g. "coal-badge"
|
||||
parts = name.split("-") # ["coal", "badge"]
|
||||
title_parts = [p.capitalize() for p in parts]
|
||||
return "_".join(title_parts) + ".png"
|
||||
|
||||
|
||||
def resolve_image_urls(filenames: list[str]) -> dict[str, str | None]:
|
||||
"""Use MediaWiki API to resolve image filenames to direct URLs."""
|
||||
results = {}
|
||||
# Process in batches of 50
|
||||
for i in range(0, len(filenames), 50):
|
||||
batch = filenames[i : i + 50]
|
||||
titles = "|".join(f"File:{fn}" for fn in batch)
|
||||
cmd = [
|
||||
"curl",
|
||||
"-s",
|
||||
f"{MEDIAWIKI_API}?action=query&titles={titles}"
|
||||
"&prop=imageinfo&iiprop=url&format=json",
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
data = json.loads(result.stdout)
|
||||
|
||||
# Build normalization map (API normalizes underscores to spaces)
|
||||
norm_map = {}
|
||||
for entry in data.get("query", {}).get("normalized", []):
|
||||
norm_map[entry["to"]] = entry["from"]
|
||||
|
||||
pages = data.get("query", {}).get("pages", {})
|
||||
for page in pages.values():
|
||||
title = page.get("title", "").replace("File:", "")
|
||||
# Map back to original underscore form
|
||||
original = norm_map.get(f"File:{title}", f"File:{title}").replace(
|
||||
"File:", ""
|
||||
)
|
||||
imageinfo = page.get("imageinfo", [])
|
||||
if imageinfo:
|
||||
results[original] = imageinfo[0]["url"]
|
||||
else:
|
||||
results[original] = None
|
||||
return results
|
||||
|
||||
|
||||
def download_file(url: str, dest: Path) -> bool:
|
||||
"""Download a file using curl."""
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
result = subprocess.run(
|
||||
["curl", "-sL", "-o", str(dest), url],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.returncode == 0 and dest.exists() and dest.stat().st_size > 0
|
||||
|
||||
|
||||
def main():
|
||||
missing = get_missing_badges()
|
||||
if not missing:
|
||||
print("All badge images already exist!")
|
||||
return
|
||||
|
||||
print(f"Missing {len(missing)} badge images:")
|
||||
for b in missing:
|
||||
print(f" {b}")
|
||||
|
||||
# Build mapping: badge_path -> bulbapedia_filename
|
||||
path_to_filename = {}
|
||||
for badge_path in missing:
|
||||
path_to_filename[badge_path] = badge_path_to_bulbapedia_filename(badge_path)
|
||||
|
||||
print(f"\nResolving {len(path_to_filename)} image URLs from Bulbapedia...")
|
||||
filenames = list(set(path_to_filename.values()))
|
||||
url_map = resolve_image_urls(filenames)
|
||||
|
||||
# Download
|
||||
success = 0
|
||||
failed = []
|
||||
for badge_path, bp_filename in sorted(path_to_filename.items()):
|
||||
url = url_map.get(bp_filename)
|
||||
if not url:
|
||||
print(f" FAILED: {badge_path} (no URL for {bp_filename})")
|
||||
failed.append((badge_path, bp_filename))
|
||||
continue
|
||||
|
||||
dest = BADGES_DIR / Path(badge_path).name
|
||||
if download_file(url, dest):
|
||||
print(f" OK: {badge_path}")
|
||||
success += 1
|
||||
else:
|
||||
print(f" FAILED: {badge_path} (download error)")
|
||||
failed.append((badge_path, bp_filename))
|
||||
|
||||
print(f"\nDownloaded: {success}/{len(missing)}")
|
||||
if failed:
|
||||
print(f"Failed ({len(failed)}):")
|
||||
for badge_path, bp_filename in failed:
|
||||
print(f" {badge_path} -> {bp_filename}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
380
scripts/fetch_boss_sprites.py
Normal file
380
scripts/fetch_boss_sprites.py
Normal file
@@ -0,0 +1,380 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user