Minor: Add Asian Pokemon Card Support (#18)

This commit is contained in:
Sebastian Dine
2026-07-22 11:13:42 +02:00
committed by GitHub
parent e5c830e945
commit c9e6bc2b6b
87 changed files with 78068 additions and 162 deletions
+147
View File
@@ -0,0 +1,147 @@
# Japanese Pokémon EN catalog ETL
Offline pipeline that builds `ui_wx/assets/pokemon_jp_en_catalog.json` for the
CCM3 Japanese Pokémon module. The C++ app loads this file at startup; it does
**not** scrape Bulbapedia or PokéAPI at runtime.
## Output schema
```json
{
"sets": {
"PMCG1": {
"name_en": "Expansion Pack",
"name_ja": "拡張パック",
"releaseDate": "1996/10/20"
}
},
"prints": [
{
"set_id": "PMCG1",
"local_id": "073",
"name_en": "Switch",
"name_ja": "ポケモンいれかえ",
"name_en_source": "trainer-table",
"tcgplayer_id": "575596"
},
{
"set_id": "PMCG4",
"local_id": "017",
"name_en": "Dark Charizard",
"name_ja": "わるいリザードン",
"name_en_source": "species-table-variant",
"tcgplayer_id": "575744"
}
]
}
```
`name_en_source` is one of `bulbapedia` | `species-table` | `species-table-variant` |
`trainer-table` | `energy-table` | `manual` | `tcgdex-thirdparty`.
Optional gap-fill fields (classic JA when TCGdex has no CDN scan):
- `tcgplayer_id` — TCGPlayer product id from data-asia `thirdParty.tcgplayer`
(PMCG and other classic sets). Runtime builds
`https://product-images.tcgplayer.com/fit-in/437x437/{id}.jpg`
- `image_url` — explicit HTTPS URL (wins over `tcgplayer_id` when both set).
Sources: City Gym bundled `asset:pokemon_jp_classic/...` paths, or neo1neo4
Japanese CardIndex scans written by `enrich_neo_image_urls.py` (JP only;
empty `image_url` → card-back when no JP scan exists).
## Suggested steps
1. Snapshot TCGdex `GET /v2/ja/sets` into `_tcgdex_sets.json`.
2. Maintain curated English display names in `set_en_names.json` (set id → EN).
3. Run `merge_set_en_catalog.py` to emit set EN names into the catalog (also
refreshes classic TCGdex-missing products from `classic_missing_sets.json`).
4. For Original-era products TCGdex omits (City Gym theme decks, Expansion
Sheets, Southern Islands), maintain `classic_missing_sets.json` +
`classic_missing_prints.json` and run `merge_classic_missing.py`. Print
`local_id`s are sequential `001`… within each product. Owner Pokémon use
full English titles (e.g. `Erika's Oddish`), not bare species names.
For Bulbapedia **Unnumbered Promotional cards**, run
`harvest_unnumbered_promos.py` to refresh the `UnnumberedPromo` set + prints,
then `enrich_unnumbered_promo_images.py` to write Bulbagarden Archives
`image_url` values that prefer Japanese / Unnumbered Promotional scans
(reprint/gallery) over English Wizards primary `|image=` files. Binding is
print-identity-aware (set/page tokens, no bare-species page fallback) so
unrelated Mewtwo promos do not share one WHF scan. EN-only Bulbapedia pages
leave `image_url` empty (card-back) — never store Wizards/Base Set EN
scans for Pokemon (Japan). Also fills `name_ja` when present. Then run
`merge_classic_missing.py`.
Cardmarket labels some Expansion Sheet / Vending Pokémon as EXP/EXS; those
may still be filed under `UnnumberedPromo` here (e.g. `Mewtwo (Vending S1)` /
`Mewtwo (Vending S3)`). Auto-detect matches bare species names as whole
tokens (`Mewtwo``Team GR's Mewtwo`, `Mewtwo Strikes Back (…)`, not `Mew`).
Runtime UX for this set (no Set # field; Next cycles synthetic localIds;
modeless print-preview popup) is documented under
`docs/assets-and-info-apis.md`**Sets without printed collector numbers**.
For printing-accurate City Gym deck scans, run
`fetch_classic_gym_images.py` and store deck-specific `image_url` values as
`asset:pokemon_jp_classic/<setId>/<localId>.jpg`. Do **not** reuse PMCG
donor `tcgplayer_id`s for City Gym deck exclusives; that shows the wrong
Leaders' Stadium art.
5. Extend `non_pokemon_en_by_ja.json` when new Trainer/Energy English aliases
are needed (JA name → EN display name; covers reprints of the same JA name).
Baseline coverage: **first 15 chronological TCGdex JA main sets**
(`PMCG1``PMCG6`, `neo1``neo4`, `VS1`, `web1`, `E1``E3`). See
`docs/assets-and-info-apis.md`**Extending Trainer/Energy English aliases**.
6. Run `enrich_preview_images.py` to merge from TCGdex cards-database
`data-asia`:
- `tcgplayer_id` for classic-image gap-fill (where data-asia exposes it)
- `name_ja` from card sources
- `name_en` via National Dex → English species table (`species_en.json`)
for ordinary Pokémon with `dexId` (e.g. Blastoise → `032`, Mewtwo → `050`)
- **Variant full titles** when `name_ja` matches a known prefix + `dexId`
(owner gym leaders, Rocket's, Dark, Light, Shining — e.g. `Erika's Oddish`,
`Dark Charizard`, `Shining Celebi`). Upgrades existing bare `species-table`
rows on re-enrich. Tagged `species-table-variant`.
- `name_en` via `non_pokemon_en_by_ja.json` for Trainer/Energy
(e.g. Switch ← `ポケモンいれかえ` → localId `073`)
7. Run `enrich_neo_image_urls.py` after enrich when neo1neo4 previews need
gap-fill. Writes `image_url` from **Japanese** CardIndex set scans (never
English pokemontcg.io). Matching is by English card name within the JA neo
set. Misses clear `image_url` (card-back). Use `--overwrite` to replace
stale EN URLs. See `docs/assets-and-info-apis.md`.
8. Optionally refine `prints[]` name fields via Bulbapedia joins.
At runtime, `JapanesePokemonSetSource` prefers catalog `name_en` and **never**
leaves Japanese TCGdex names in `Set.name` (falls back to the set id). It also
injects the classic missing products listed above. `JapanesePokemonCardPreviewSource`
uses catalog `tcgplayer_id` / `image_url` only for the exact `setId`+`localId`
when TCGdex has no scan, and falls back to catalog-only Auto-detect/preview
when TCGdex has no set detail for a curated classic product.
## Commands
```bash
# After refreshing tools/pokemon_jp/_tcgdex_sets.json and set_en_names.json:
python tools/pokemon_jp/merge_set_en_catalog.py
# After editing classic_missing_sets.json / classic_missing_prints.json:
python tools/pokemon_jp/merge_classic_missing.py
# Refresh UnnumberedPromo prints from Bulbapedia, enrich JP images, then merge:
python tools/pokemon_jp/harvest_unnumbered_promos.py
python tools/pokemon_jp/enrich_unnumbered_promo_images.py --force
python tools/pokemon_jp/merge_classic_missing.py
# After updating bundled City Gym deck scans:
python tools/pokemon_jp/fetch_classic_gym_images.py
# Harvest TCGPlayer ids + species/trainer/variant English aliases:
python tools/pokemon_jp/enrich_preview_images.py
# Fill neo1neo4 image_url from Japanese CardIndex scans (run after enrich):
python tools/pokemon_jp/enrich_neo_image_urls.py
# Replace / clear previously written EN pokemontcg.io neo URLs:
python tools/pokemon_jp/enrich_neo_image_urls.py --overwrite
```
Seed-only catalog writer (minimal rows):
```bash
python tools/pokemon_jp/build_catalog.py \
--out ui_wx/assets/pokemon_jp_en_catalog.json
```
+1
View File
@@ -0,0 +1 @@
#REDIRECT [[Fossil (TCG)]]
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Scaffold ETL for Japanese Pokémon EN catalog JSON.
Seed mode (default) writes a small valid catalog matching the committed asset
schema. Extend this script to pull TCGdex + Bulbapedia joins for full coverage.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
SEED = {
"sets": {
"PMCG1": {
"name_en": "Expansion Pack",
"name_ja": "拡張パック",
"releaseDate": "1996/10/20",
},
"PMCG2": {
"name_en": "Pokémon Jungle",
"name_ja": "ポケモンジャングル",
"releaseDate": "1997/03/14",
},
"SV1a": {
"name_en": "Triplet Beat",
"name_ja": "トリプレットビート",
"releaseDate": "2023/03/10",
},
"SV4a": {
"name_en": "Shiny Treasure ex",
"name_ja": "シャイニートレジャーex",
"releaseDate": "2023/11/10",
},
},
"prints": [
{
"set_id": "PMCG1",
"local_id": "014",
"name_en": "Charmander",
"name_ja": "ヒトカゲ",
"name_en_source": "bulbapedia",
},
{
"set_id": "PMCG1",
"local_id": "021",
"name_en": "Charizard",
"name_ja": "リザードン",
"name_en_source": "bulbapedia",
},
{
"set_id": "SV1a",
"local_id": "001",
"name_en": "Tropius",
"name_ja": "トロピウス",
"name_en_source": "bulbapedia",
},
],
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--out",
type=Path,
default=Path("ui_wx/assets/pokemon_jp_en_catalog.json"),
help="Output catalog path",
)
args = parser.parse_args()
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(SEED, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(f"Wrote seed catalog to {args.out}")
if __name__ == "__main__":
main()
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Build a full Japanese Pokémon EN set catalog from TCGdex + Wikipedia.
Strategy:
1. Pull JA set list from TCGdex (exclude CS*).
2. Pull Wikipedia 'List of Pokémon Trading Card Game sets' wikitext and
extract English / Japanese name pairs from {{lang|ja|...}} near bold titles.
3. Match by Japanese name (after TCGdex overrides). Unmatched sets get a
Latin fallback of the set id (never leave Japanese in Set.name).
4. Fetch release dates from TCGdex set detail for catalog completeness.
"""
from __future__ import annotations
import argparse
import json
import re
import time
import urllib.request
from pathlib import Path
UA = "CardCollectionManager3-ETL/0.1 (local; set-catalog)"
JA_OVERRIDES = {
"SV4a": "シャイニートレジャーex",
}
def http_json(url: str):
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=60) as resp:
return json.load(resp)
def http_text_params(base: str, params: dict) -> dict:
from urllib.parse import urlencode
return http_json(base + "?" + urlencode(params))
def contains_cjk(s: str) -> bool:
return any(
"\u3040" <= ch <= "\u30ff"
or "\u3400" <= ch <= "\u4dbf"
or "\u4e00" <= ch <= "\u9fff"
or "\uf900" <= ch <= "\ufaff"
for ch in s
)
def wiki_en_ja_pairs() -> dict[str, str]:
"""Map Japanese set name -> English display name from Wikipedia."""
data = http_text_params(
"https://en.wikipedia.org/w/api.php",
{
"action": "parse",
"page": "List of Pokémon Trading Card Game sets",
"prop": "wikitext",
"format": "json",
"formatversion": "2",
},
)
wt = data["parse"]["wikitext"]
# '''English Name''' ... lang|ja|Japanese Name
pairs: dict[str, str] = {}
for m in re.finditer(
r"'''([^']+)'''(?P<body>.{0,260}?)\{\{lang\|ja\|(?P<ja>[^}]+)\}\}",
wt,
flags=re.DOTALL,
):
en = m.group(1).strip()
ja = m.group("ja").strip()
# Drop template noise / multi-ja (take first segment before &)
ja = re.split(r"\s*&\s*", ja)[0].strip()
ja = re.sub(r"<[^>]+>", "", ja).strip()
if not ja or not en or not contains_cjk(ja):
continue
# Prefer first English seen for a JA name
pairs.setdefault(ja, en)
return pairs
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--out",
type=Path,
default=Path("ui_wx/assets/pokemon_jp_en_catalog.json"),
)
parser.add_argument(
"--fetch-dates",
action="store_true",
help="Hit TCGdex set detail for each set (slow, ~1 req/s).",
)
args = parser.parse_args()
raw_sets = http_json("https://api.tcgdex.net/v2/ja/sets")
sets = [s for s in raw_sets if not str(s.get("id", "")).startswith("CS")]
print(f"TCGdex JA sets (excl CS*): {len(sets)}")
ja_to_en = wiki_en_ja_pairs()
print(f"Wikipedia JA->EN pairs: {len(ja_to_en)}")
# Keep existing print rows if present
existing_prints = []
if args.out.exists():
try:
prev = json.loads(args.out.read_text(encoding="utf-8"))
existing_prints = prev.get("prints", [])
except Exception:
pass
catalog_sets: dict[str, dict] = {}
matched = 0
for entry in sets:
sid = entry["id"]
name_ja = JA_OVERRIDES.get(sid, entry.get("name", ""))
name_en = ja_to_en.get(name_ja, "")
if not name_en:
# Fuzzy: Wikipedia sometimes includes extra spaces / fullwidth
for ja, en in ja_to_en.items():
if ja in name_ja or name_ja in ja:
name_en = en
break
if name_en:
matched += 1
else:
# Never leave CJK in the UI set picker — fall back to set id.
name_en = sid
catalog_sets[sid] = {
"name_en": name_en,
"name_ja": name_ja,
"releaseDate": "",
}
print(f"Matched Wikipedia EN names: {matched}/{len(sets)}")
print(f"Fallback to set id: {len(sets) - matched}")
if args.fetch_dates:
for i, sid in enumerate(catalog_sets):
try:
detail = http_json(f"https://api.tcgdex.net/v2/ja/sets/{sid}")
rd = detail.get("releaseDate") or ""
if rd:
catalog_sets[sid]["releaseDate"] = rd.replace("-", "/")
except Exception as exc:
print(f" date fail {sid}: {exc}")
time.sleep(0.35)
if (i + 1) % 20 == 0:
print(f" dates {i+1}/{len(catalog_sets)}")
out = {"sets": catalog_sets, "prints": existing_prints}
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(out, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
print(f"Wrote {args.out}")
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,57 @@
{
"ExpSheet1": {
"name_en": "Expansion Sheet Series 1",
"name_ja": "拡張シート 第1弾",
"releaseDate": "1998/03/23"
},
"NiviCG": {
"name_en": "Nivi City Gym",
"name_ja": "ニビシティジム タケシ",
"releaseDate": "1998/04/26"
},
"HanadaCG": {
"name_en": "Hanada City Gym",
"name_ja": "ハナダシティジム カスミ",
"releaseDate": "1998/04/26"
},
"ExpSheet2": {
"name_en": "Expansion Sheet Series 2",
"name_ja": "拡張シート 第2弾",
"releaseDate": "1998/06/17"
},
"KuchibaCG": {
"name_en": "Kuchiba City Gym",
"name_ja": "クチバシティジム マチス",
"releaseDate": "1998/07/25"
},
"TamamushiCG": {
"name_en": "Tamamushi City Gym",
"name_ja": "タマムシシティジム エリカ",
"releaseDate": "1998/07/25"
},
"ExpSheet3": {
"name_en": "Expansion Sheet Series 3",
"name_ja": "拡張シート 第3弾",
"releaseDate": "1998/11/24"
},
"YamabukiCG": {
"name_en": "Yamabuki City Gym",
"name_ja": "ヤマブキシティジム ナツメ",
"releaseDate": "1999/02/26"
},
"GurenTG": {
"name_en": "Guren Town Gym",
"name_ja": "グレンタウンジム カツラ",
"releaseDate": "1999/02/26"
},
"SouthernIslands": {
"name_en": "Southern Islands",
"name_ja": "サザンアイランド",
"releaseDate": "1999/07/17"
},
"UnnumberedPromo": {
"name_en": "Unnumbered Promotional cards",
"name_ja": "番号なしプロモーションカード",
"releaseDate": "1997/03/06"
}
}
+289
View File
@@ -0,0 +1,289 @@
#!/usr/bin/env python3
"""Fill neo1neo4 catalog image_url from Japanese CardIndex scans only.
TCGdex JA neo sets have image:null. This ETL scrapes CardIndex Japanese set
pages (Awakening Legends, etc.) and writes HTTPS image_url values for exact
JA setId+localId catalog rows.
Policy (UnnumberedPromo parity): Japanese scans only. If CardIndex has no JP
image for a print, image_url is cleared — never store English pokemontcg.io
art as a fallback.
Usage:
python tools/pokemon_jp/enrich_neo_image_urls.py
python tools/pokemon_jp/enrich_neo_image_urls.py --dry-run
python tools/pokemon_jp/enrich_neo_image_urls.py --overwrite
python tools/pokemon_jp/enrich_neo_image_urls.py --overwrite --limit 20
"""
from __future__ import annotations
import argparse
import json
import re
import time
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
OUT = ROOT / "ui_wx" / "assets" / "pokemon_jp_en_catalog.json"
NEO_SETS = frozenset({"neo1", "neo2", "neo3", "neo4"})
UA = {
"User-Agent": (
"CCM3-pokemon-jp-etl/1.0 "
"(local; +https://github.com/sebastiandine/Card-Collection-Manager-3)"
)
}
# JA neo set id -> (CardIndex set page slug, image CDN folder)
SET_META: dict[str, tuple[str, str]] = {
"neo1": ("gold-silver-to-a-new-world", "neo-jp-gold-silver"),
"neo2": ("crossing-the-ruins", "neo-jp-crossing-ruins"),
"neo3": ("awakening-legends", "neo-jp-awakening-legends"),
"neo4": ("darkness-and-to-light", "neo-jp-darkness-light"),
}
IMG_RE = re.compile(
r"https://images\.cardindex\.co/cardindex-images/cards/"
r"(?P<folder>[^/\"']+)/(?P<file>[^\"'\s>]+)\.(?P<ext>jpe?g|png|webp)",
re.IGNORECASE,
)
def log(msg: str) -> None:
try:
print(msg, flush=True)
except UnicodeEncodeError:
print(msg.encode("ascii", errors="replace").decode("ascii"), flush=True)
def http_get(url: str, timeout: float = 60.0) -> str:
req = urllib.request.Request(url, headers=UA)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read().decode("utf-8", "replace")
def normalize_name(s: str) -> str:
"""Lowercase alnum tokens for matching catalog names to CardIndex slugs."""
s = s.lower().replace("\u2019", "'").replace("'", "")
s = re.sub(r"[^a-z0-9]+", " ", s)
return " ".join(s.split())
def slug_base_name(slug: str) -> str:
"""shining-magikarp-129 / balloon-berry-promo -> shining magikarp / balloon berry."""
s = slug.strip().lower()
s = re.sub(r"-promo$", "", s)
s = re.sub(r"-\d+$", "", s)
return normalize_name(s.replace("-", " "))
def pick_set_image(html: str, image_folder: str, card_slug: str) -> str | None:
"""Prefer full-size JP scan in this set's CDN folder for this card slug."""
folder_l = image_folder.lower()
slug_l = card_slug.lower()
full: list[str] = []
small: list[str] = []
for m in IMG_RE.finditer(html):
if m.group("folder").lower() != folder_l:
continue
file_stem = m.group("file").lower()
# Require the card's own slug (with optional -small).
if not (file_stem == slug_l or file_stem == f"{slug_l}-small"):
# Also accept promo variant files named "{base}-promo".
base = re.sub(r"-\d+$", "", slug_l)
if not (
file_stem == f"{base}-promo"
or file_stem == f"{base}-promo-small"
or file_stem.startswith(f"{slug_l}")
):
continue
url = m.group(0)
if file_stem.endswith("-small"):
small.append(url)
else:
full.append(url)
if full:
# Prefer exact slug match over promo/other.
for u in full:
if f"/{slug_l}." in u.lower():
return u
for u in full:
if f"/{slug_l}-" not in u.lower() or "-promo." in u.lower():
return u
return full[0]
if small:
# Upgrade -small to full-size URL when possible.
u = small[0]
return re.sub(r"-small\.(jpe?g|png|webp)$", r".\1", u, flags=re.I)
return None
def scrape_set_index(
set_id: str, *, sleep_s: float
) -> dict[str, list[tuple[str, str]]]:
"""Return normalize_name -> [(card_slug, image_url), ...] for one neo set."""
page_slug, image_folder = SET_META[set_id]
set_url = f"https://www.cardindex.co/pokemon-cards/{page_slug}"
log(f"scraping set index {set_id}: {set_url}")
html = http_get(set_url)
card_slugs = sorted(
set(
re.findall(
rf"/pokemon-cards/{re.escape(page_slug)}/([a-z0-9\-]+)",
html,
)
)
)
log(f" {len(card_slugs)} card pages")
by_name: dict[str, list[tuple[str, str]]] = {}
for i, slug in enumerate(card_slugs, start=1):
card_url = f"https://www.cardindex.co/pokemon-cards/{page_slug}/{slug}"
try:
time.sleep(sleep_s)
card_html = http_get(card_url)
except (urllib.error.URLError, TimeoutError) as exc:
log(f" [{i}/{len(card_slugs)}] FAIL {slug}: {exc}")
continue
img = pick_set_image(card_html, image_folder, slug)
if not img:
log(f" [{i}/{len(card_slugs)}] no JP image {slug}")
continue
name = slug_base_name(slug)
by_name.setdefault(name, []).append((slug, img))
log(f" [{i}/{len(card_slugs)}] {name!r} <- {img}")
return by_name
def resolve_url_for_print(
name_en: str, index: dict[str, list[tuple[str, str]]]
) -> str | None:
key = normalize_name(name_en)
if not key:
return None
hits = index.get(key) or []
if not hits:
return None
# Unique image only — ambiguous Unown / multi-print names stay empty.
urls = sorted({u for _slug, u in hits})
if len(urls) == 1:
return urls[0]
return None
def enrich_neo_images(
catalog: dict,
*,
dry_run: bool,
overwrite: bool,
sleep_s: float,
limit: int,
out_path: Path,
) -> tuple[int, int, int, int]:
prints = catalog.get("prints", [])
candidates = [
p
for p in prints
if p.get("set_id") in NEO_SETS and (p.get("name_en") or "").strip()
]
if not overwrite:
candidates = [
p for p in candidates if not (p.get("image_url") or "").strip()
]
if limit > 0:
candidates = candidates[:limit]
# Scrape only the sets we need.
needed_sets = sorted({str(p["set_id"]) for p in candidates})
indexes: dict[str, dict[str, list[tuple[str, str]]]] = {}
for sid in needed_sets:
indexes[sid] = scrape_set_index(sid, sleep_s=sleep_s)
filled = 0
changed = 0
missed = 0
updates = 0
def persist() -> None:
if dry_run:
return
out_path.write_text(
json.dumps(catalog, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
for i, p in enumerate(candidates, start=1):
sid = str(p["set_id"])
lid = str(p["local_id"])
name_en = str(p["name_en"]).strip()
prev = (p.get("image_url") or "").strip()
log(f"[{i}/{len(candidates)}] {sid}-{lid} {name_en!r}")
url = resolve_url_for_print(name_en, indexes.get(sid, {}))
if url:
if not dry_run:
p["image_url"] = url
filled += 1
if url != prev:
changed += 1
updates += 1
log(f" -> {url}" + (f" (was {prev})" if prev else ""))
else:
log(f" -> {url} (unchanged)")
else:
missed += 1
if prev:
if not dry_run:
p.pop("image_url", None)
changed += 1
updates += 1
log(f" -> (miss, cleared {prev})")
else:
log(" -> (miss)")
if updates >= 25:
persist()
updates = 0
log(f" checkpoint wrote {out_path}")
persist()
return len(candidates), filled, changed, missed
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--out", type=Path, default=OUT)
ap.add_argument("--dry-run", action="store_true")
ap.add_argument(
"--overwrite",
action="store_true",
help="Re-resolve neo prints that already have image_url (clears EN URLs)",
)
ap.add_argument("--limit", type=int, default=0, help="Max neo prints to process")
ap.add_argument("--sleep", type=float, default=0.35, help="Seconds between HTTP calls")
args = ap.parse_args()
if not args.out.is_file():
raise SystemExit(f"catalog not found: {args.out}")
catalog = json.loads(args.out.read_text(encoding="utf-8"))
total, filled, changed, missed = enrich_neo_images(
catalog,
dry_run=args.dry_run,
overwrite=args.overwrite,
sleep_s=args.sleep,
limit=args.limit,
out_path=args.out,
)
print(
f"neo image_url: candidates={total} filled={filled} changed={changed} "
f"missed={missed}"
+ (" (dry-run)" if args.dry_run else f" wrote {args.out}"),
flush=True,
)
if __name__ == "__main__":
main()
+471
View File
@@ -0,0 +1,471 @@
#!/usr/bin/env python3
"""Enrich pokemon_jp_en_catalog.json prints from TCGdex data-asia.
Harvests per-card:
- tcgplayer_id (thirdParty.tcgplayer) for classic-image gap-fill
- name_ja from the card source
- name_en via National Dex id → English species name (when dexId present)
- name_en for owner / Rocket's / Dark / Light / Shining variants (full titles)
- name_en for Trainer/Energy via tools/pokemon_jp/non_pokemon_en_by_ja.json
English names are required for Auto-detect when the user types "Mewtwo" /
"Switch" / "Erika's Oddish" / "Dark Charizard" etc. — TCGdex set résumés only
expose Japanese names.
Usage:
python tools/pokemon_jp/enrich_preview_images.py
python tools/pokemon_jp/enrich_preview_images.py --data-asia path/to/data-asia
"""
from __future__ import annotations
import argparse
import io
import json
import re
import shutil
import urllib.request
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
OUT = ROOT / "ui_wx" / "assets" / "pokemon_jp_en_catalog.json"
CACHE_DIR = Path(__file__).resolve().parent / "_tcgdex_cards_database"
SPECIES_CACHE = Path(__file__).resolve().parent / "species_en.json"
NON_POKEMON_EN = Path(__file__).resolve().parent / "non_pokemon_en_by_ja.json"
ZIP_URL = "https://github.com/tcgdex/cards-database/archive/refs/heads/master.zip"
# National-dex-ordered English names (index 0 = Bulbasaur / dex 1).
SPECIES_URL = (
"https://raw.githubusercontent.com/sindresorhus/pokemon/main/data/en.json"
)
TCGPLAYER_RE = re.compile(r"tcgplayer\s*:\s*(\d+)")
NAME_JA_RE = re.compile(r"name\s*:\s*\{\s*ja\s*:\s*\"([^\"]+)\"", re.DOTALL)
DEX_RE = re.compile(r"dexId\s*:\s*\[\s*(\d+)")
CATEGORY_RE = re.compile(r'category\s*:\s*"([^"]+)"')
LOCAL_ID_RE = re.compile(r"^[0-9A-Za-z]+$")
# Chronological first 15 main Japanese expansions in TCGdex (for coverage checks).
# Longest JA prefixes first. Maps to English product-title prefix + National Dex species.
VARIANT_JA_PREFIXES: list[tuple[str, str]] = [
("R団の", "Rocket's "),
("エリカの", "Erika's "),
("タケシの", "Brock's "),
("カスミの", "Misty's "),
("マチスの", "Lt. Surge's "),
("ナツメの", "Sabrina's "),
("カツラの", "Blaine's "),
("キョウの", "Koga's "),
("サカキの", "Giovanni's "),
("ヤナギの", "Pryce's "),
("カンナの", "Lorelei's "),
("シバの", "Bruno's "),
("キクコの", "Agatha's "),
("やさしい", "Light "),
("ひかる", "Shining "),
("輝く", "Shining "), # neo Destiny upstream garble
("軽い", "Light "), # neo Destiny upstream garble
("わるい", "Dark "),
("暗い", "Dark "), # neo Destiny upstream garble
("ダーク", "Dark "), # neo Destiny upstream garble (e.g. ダークアリアドス)
]
PROTECTED_NAME_EN_SOURCES = frozenset(
{"manual", "trainer-table", "energy-table", "bulbapedia", "tcgdex-thirdparty"}
)
FIRST15_SETS = [
"PMCG1",
"PMCG2",
"PMCG3",
"PMCG4",
"PMCG5",
"PMCG6",
"neo1",
"neo2",
"neo3",
"neo4",
"VS1",
"web1",
"E1",
"E2",
"E3",
]
def download_data_asia(dest: Path) -> Path:
dest.mkdir(parents=True, exist_ok=True)
marker = dest / "data-asia"
if marker.is_dir() and any(marker.rglob("*.ts")):
return marker
print(f"Downloading {ZIP_URL}")
req = urllib.request.Request(ZIP_URL, headers={"User-Agent": "ccm-pokemonjp-etl"})
with urllib.request.urlopen(req, timeout=180) as resp:
blob = resp.read()
with zipfile.ZipFile(io.BytesIO(blob)) as zf:
members = [n for n in zf.namelist() if "/data-asia/" in n.replace("\\", "/")]
for name in members:
parts = Path(name).parts
if "data-asia" not in parts:
continue
idx = parts.index("data-asia")
rel = Path(*parts[idx:])
target = dest / rel
if name.endswith("/"):
target.mkdir(parents=True, exist_ok=True)
continue
target.parent.mkdir(parents=True, exist_ok=True)
with zf.open(name) as src, open(target, "wb") as out:
shutil.copyfileobj(src, out)
if not marker.is_dir():
raise SystemExit("data-asia missing after zip extract")
return marker
def load_species_en() -> dict[int, str]:
"""Map National Dex id -> English species name."""
if SPECIES_CACHE.is_file():
raw = json.loads(SPECIES_CACHE.read_text(encoding="utf-8"))
else:
print(f"Downloading {SPECIES_URL}")
req = urllib.request.Request(
SPECIES_URL, headers={"User-Agent": "ccm-pokemonjp-etl"}
)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = json.loads(resp.read().decode("utf-8"))
SPECIES_CACHE.write_text(
json.dumps(raw, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
# File is a list: ["Bulbasaur", "Ivysaur", ...]
if isinstance(raw, list):
return {i + 1: name for i, name in enumerate(raw) if isinstance(name, str)}
if isinstance(raw, dict):
return {int(k): str(v) for k, v in raw.items()}
raise SystemExit("unexpected species_en.json shape")
def load_non_pokemon_en() -> dict[str, str]:
"""Map Japanese Trainer/Energy (etc.) names → English display names."""
if not NON_POKEMON_EN.is_file():
return {}
raw = json.loads(NON_POKEMON_EN.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
raise SystemExit("non_pokemon_en_by_ja.json must be a JSON object")
return {str(k): str(v) for k, v in raw.items() if str(k).strip() and str(v).strip()}
def extract_cards(data_asia: Path) -> dict[tuple[str, str], dict]:
"""Map (setId, localId) -> {tcgplayer_id, name_ja, dex_id}."""
out: dict[tuple[str, str], dict] = {}
for path in data_asia.rglob("*.ts"):
try:
rel = path.relative_to(data_asia)
except ValueError:
continue
parts = rel.parts
if len(parts) != 3:
continue
set_id = parts[1]
local_id = path.stem
if not LOCAL_ID_RE.match(local_id):
continue
text = path.read_text(encoding="utf-8", errors="replace")
entry: dict = {}
m = TCGPLAYER_RE.search(text)
if m:
entry["tcgplayer_id"] = m.group(1)
m = NAME_JA_RE.search(text)
if m:
entry["name_ja"] = m.group(1)
m = DEX_RE.search(text)
if m:
entry["dex_id"] = int(m.group(1))
m = CATEGORY_RE.search(text)
category = m.group(1) if m else ""
# TCGdex PMCG1-102 Fighting Energy has an empty Japanese name in source.
if (
set_id == "PMCG1"
and local_id == "102"
and category == "Energy"
and not entry.get("name_ja")
):
entry["name_ja"] = "基本闘エネルギー"
if not entry:
continue
out[(set_id, local_id)] = entry
return out
def variant_en_prefix(name_ja: str) -> str | None:
"""Return English title prefix for a known JA variant pattern, or None."""
for ja_prefix, en_prefix in VARIANT_JA_PREFIXES:
if name_ja.startswith(ja_prefix):
return en_prefix
return None
def compose_species_name_en(
name_ja: str, dex_id: int | None, species_en: dict[int, str]
) -> tuple[str, str] | None:
"""Return (name_en, name_en_source) from dex + optional variant prefix."""
if dex_id is None or dex_id not in species_en:
return None
species = species_en[dex_id]
prefix = variant_en_prefix(name_ja)
if prefix:
return prefix + species, "species-table-variant"
return species, "species-table"
def upgrade_variant_titles(
prints: list[dict],
cards: dict[tuple[str, str], dict],
species_en: dict[int, str],
) -> int:
"""Upgrade bare species-table rows to full variant English titles."""
upgraded = 0
for p in prints:
if (p.get("name_en_source") or "") in PROTECTED_NAME_EN_SOURCES:
continue
ja = (p.get("name_ja") or "").strip()
if not ja or variant_en_prefix(ja) is None:
continue
sid = str(p.get("set_id", ""))
lid = str(p.get("local_id", ""))
dex = cards.get((sid, lid), {}).get("dex_id")
composed = compose_species_name_en(ja, dex, species_en)
if composed is None:
continue
full_en, src = composed
if p.get("name_en") == full_en and p.get("name_en_source") == src:
continue
p["name_en"] = full_en
p["name_en_source"] = src
upgraded += 1
return upgraded
def verify_first15_trainer_coverage(
data_asia: Path, non_pokemon_en: dict[str, str]
) -> list[str]:
"""Return unique Trainer/Energy JA names in FIRST15 still missing from the map."""
missing: set[str] = set()
for path in data_asia.rglob("*.ts"):
try:
rel = path.relative_to(data_asia)
except ValueError:
continue
parts = rel.parts
if len(parts) != 3 or parts[1] not in FIRST15_SETS:
continue
text = path.read_text(encoding="utf-8", errors="replace")
catm = CATEGORY_RE.search(text)
if not catm or catm.group(1) == "Pokemon":
continue
jam = NAME_JA_RE.search(text)
ja = jam.group(1) if jam else ""
if path.stem == "102" and parts[1] == "PMCG1" and not ja:
ja = "基本闘エネルギー"
if not ja:
missing.add(f"{parts[1]}/{path.stem} <empty name_ja>")
continue
if ja not in non_pokemon_en:
missing.add(ja)
return sorted(missing)
def merge_catalog(
catalog: dict,
cards: dict[tuple[str, str], dict],
species_en: dict[int, str],
non_pokemon_en: dict[str, str],
) -> tuple[int, int, int, int]:
prints = catalog.setdefault("prints", [])
by_key: dict[tuple[str, str], dict] = {}
for p in prints:
sid = str(p.get("set_id", ""))
lid = str(p.get("local_id", ""))
if sid and lid:
by_key[(sid, lid)] = p
updated = 0
added = 0
species_named = 0
table_named = 0
for (sid, lid), meta in sorted(cards.items()):
existing = by_key.get((sid, lid))
if existing is None:
existing = {
"set_id": sid,
"local_id": lid,
"name_en": "",
"name_ja": "",
"name_en_source": "",
}
prints.append(existing)
by_key[(sid, lid)] = existing
added += 1
changed = False
pid = meta.get("tcgplayer_id")
if pid and existing.get("tcgplayer_id") != pid:
existing["tcgplayer_id"] = pid
changed = True
name_ja = meta.get("name_ja", "")
if name_ja and not (existing.get("name_ja") or "").strip():
existing["name_ja"] = name_ja
changed = True
if not (existing.get("name_en") or "").strip():
dex = meta.get("dex_id")
ja_key = (existing.get("name_ja") or name_ja or "").strip()
composed = compose_species_name_en(ja_key, dex, species_en)
if composed is not None:
existing["name_en"], existing["name_en_source"] = composed
species_named += 1
changed = True
else:
ja_key = (existing.get("name_ja") or name_ja or "").strip()
if ja_key and ja_key in non_pokemon_en:
existing["name_en"] = non_pokemon_en[ja_key]
# Energies vs trainers: basic energy names share a pattern.
if "エネルギー" in ja_key and ja_key.startswith("基本"):
existing["name_en_source"] = "energy-table"
elif "エネルギー" in ja_key:
existing["name_en_source"] = "energy-table"
else:
existing["name_en_source"] = "trainer-table"
table_named += 1
changed = True
if changed:
updated += 1
# Also apply the JA→EN table to existing prints that were never in data-asia
# walk (or already present with name_ja but empty name_en).
for p in prints:
if (p.get("name_en") or "").strip():
continue
ja_key = (p.get("name_ja") or "").strip()
if not ja_key or ja_key not in non_pokemon_en:
continue
p["name_en"] = non_pokemon_en[ja_key]
if "エネルギー" in ja_key:
p["name_en_source"] = "energy-table"
else:
p["name_en_source"] = "trainer-table"
table_named += 1
updated += 1
catalog["prints"] = prints
return updated, added, species_named, table_named
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--data-asia", type=Path, default=None)
ap.add_argument("--out", type=Path, default=OUT)
args = ap.parse_args()
if args.data_asia:
data_asia = args.data_asia
if not data_asia.is_dir():
raise SystemExit(f"data-asia not found: {data_asia}")
else:
data_asia = download_data_asia(CACHE_DIR)
species_en = load_species_en()
non_pokemon_en = load_non_pokemon_en()
cards = extract_cards(data_asia)
print(f"found {len(cards)} card files under {data_asia}")
print(f"non-pokemon EN map entries: {len(non_pokemon_en)}")
if args.out.exists():
catalog = json.loads(args.out.read_text(encoding="utf-8"))
else:
catalog = {"sets": {}, "prints": []}
updated, added, species_named, table_named = merge_catalog(
catalog, cards, species_en, non_pokemon_en
)
variant_upgraded = upgrade_variant_titles(
catalog["prints"], cards, species_en
)
for lid, expect_en in (
("021", "Charizard"),
("032", "Blastoise"),
("050", "Mewtwo"),
("073", "Switch"),
):
hit = next(
(
p
for p in catalog["prints"]
if p.get("set_id") == "PMCG1" and p.get("local_id") == lid
),
None,
)
if hit:
print(
f"PMCG1/{lid}: name_en={hit.get('name_en')!r} "
f"name_ja={hit.get('name_ja')!r} tp={hit.get('tcgplayer_id')}"
)
if hit.get("name_en") != expect_en:
print(f" WARNING: expected name_en {expect_en!r}")
else:
print(f"WARNING: missing PMCG1/{lid}")
gaps = verify_first15_trainer_coverage(data_asia, non_pokemon_en)
if gaps:
print(f"WARNING: {len(gaps)} FIRST15 trainer/energy JA names still unmapped:")
for ja in gaps[:30]:
print(f" - {ja}")
if len(gaps) > 30:
print(f" ... and {len(gaps) - 30} more")
else:
print(f"FIRST15 trainer/energy coverage OK ({len(FIRST15_SETS)} sets)")
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(catalog, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
# Spot-check variant titles on classic sets.
for sid, lid, expect_en in (
("PMCG5", "002", "Erika's Oddish"),
("PMCG4", "017", "Dark Charizard"),
("PMCG6", "042", "Rocket's Zapdos"),
):
hit = next(
(
p
for p in catalog["prints"]
if p.get("set_id") == sid and p.get("local_id") == lid
),
None,
)
if hit:
print(
f"{sid}/{lid}: name_en={hit.get('name_en')!r} "
f"source={hit.get('name_en_source')!r}"
)
if hit.get("name_en") != expect_en:
print(f" WARNING: expected name_en {expect_en!r}")
else:
print(f"WARNING: missing {sid}/{lid}")
print(
f"wrote {args.out}: touched={updated} added={added} "
f"species_named={species_named} table_named={table_named} "
f"variant_upgraded={variant_upgraded} "
f"prints={len(catalog['prints'])}"
)
if __name__ == "__main__":
main()
@@ -0,0 +1,567 @@
#!/usr/bin/env python3
"""Fill UnnumberedPromo image_url / name_ja from Bulbapedia card pages.
Reads tools/pokemon_jp/classic_missing_prints.json rows with set_id=UnnumberedPromo,
resolves each `bulbapedia_page` (with redirects), and prefers Japanese /
Unnumbered Promotional scans from reprint/gallery fields over the English
primary `|image=` (often a Wizards Black Star print).
If Bulbapedia only hosts an English scan, image_url is left empty (card-back)
rather than storing a misleading EN preview.
Usage:
python tools/pokemon_jp/enrich_unnumbered_promo_images.py
python tools/pokemon_jp/enrich_unnumbered_promo_images.py --force
python tools/pokemon_jp/enrich_unnumbered_promo_images.py --dry-run
python tools/pokemon_jp/enrich_unnumbered_promo_images.py --limit 20
Then:
python tools/pokemon_jp/merge_classic_missing.py
"""
from __future__ import annotations
import argparse
import json
import re
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
HERE = Path(__file__).resolve().parent
PRINTS = HERE / "classic_missing_prints.json"
SET_ID = "UnnumberedPromo"
UA = "CCM3-pokemon-jp-etl/1.0 (local; +https://github.com/sebastiandine/Card-Collection-Manager-3)"
API = "https://bulbapedia.bulbagarden.net/w/api.php"
JNAME_RE = re.compile(r"\|\s*jname\s*=\s*([^\n|]+)", re.IGNORECASE)
# |image= / |image1= / |reprint1= / |caption= / |caption2= / |recaption1=
FIELD_RE = re.compile(
r"\|\s*(image|reprint|caption|recaption)(\d*)\s*=\s*([^\n]+)",
re.IGNORECASE,
)
SKIP_IMAGE_SUBSTR = (
"attack.png",
"card_back",
"cardback",
"project_tcg",
"setsymbol",
"rare_",
"energy.png",
"tcg1_",
"tcg2_",
"misprint",
)
# Filename / caption hints that the scan is the Japanese unnumbered print.
JP_FILENAME_MARKERS = (
"corocoro",
"whf",
"fanbook",
"unnumbered",
"japanese",
"gb2",
"illustrator",
"battleroad",
"movie",
"parentchild",
"vending",
"asobikata",
"jogress",
"pokedude",
"daisuki",
"specialsheet",
"informationpack",
"howibecame",
"newgarura",
"touchgeneration",
"championleague",
"worldofillusions",
"clashatthesummit",
"blackwhitetour",
"warnerbros",
"nintendo64",
"teamgr",
"imakuni",
"tradeplease",
"hungrysnorlax",
"coolporygon", # often still EN — scored only with caption
)
# Captions that mark the Unnumbered / JP print on shared EN+JP articles.
JP_CAPTION_MARKERS = (
"unnumbered promotional",
"unnumbered promo",
"japanese",
"jpexpansion",
)
# English primary prints we must not prefer when a JP candidate exists.
EN_FILENAME_MARKERS = (
"wizardspromo",
"baseset",
"neogenesis",
"fossil",
"teamrocket",
"jungle",
"legendarycollection",
"dppromo",
"mysterious treasures",
"mysterioustreasures",
"diamondpearl",
"exdragon",
"exholon",
"exdelta",
"neodiscovery",
"neorevelations",
"neodestiny",
"gymheroes",
"gymchallenge",
"nintendopromo", # often EN Black Star; allow if also JP-captioned
)
def api(**params: object) -> dict:
qs = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
req = urllib.request.Request(f"{API}?{qs}", headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=90) as resp:
return json.load(resp)
def fetch_wikitext(page: str) -> tuple[str, str] | None:
"""Return (resolved_title, wikitext) or None if missing."""
try:
data = api(
action="parse",
page=page,
prop="wikitext",
format="json",
redirects=1,
)
except urllib.error.HTTPError:
return None
except urllib.error.URLError:
return None
if "error" in data:
return None
parsed = data.get("parse") or {}
wt = (parsed.get("wikitext") or {}).get("*")
title = parsed.get("title") or page
if not wt:
return None
return title, wt
def file_url(filename: str) -> str | None:
fname = filename.strip().replace(" ", "_")
if not fname:
return None
data = api(
action="query",
titles=f"File:{fname}",
prop="imageinfo",
iiprop="url",
format="json",
)
pages = (data.get("query") or {}).get("pages") or {}
for page in pages.values():
infos = page.get("imageinfo") or []
if infos and infos[0].get("url"):
return str(infos[0]["url"])
return None
def normalize_filename(raw: str) -> str | None:
s = raw.strip().split("|", 1)[0].strip()
s = re.sub(r"\[\[(?:File:)?([^\]|]+).*", r"\1", s, flags=re.IGNORECASE)
s = s.strip()
if not s:
return None
low = s.lower().replace(" ", "_")
if any(tok in low for tok in SKIP_IMAGE_SUBSTR):
return None
if not re.search(r"\.(jpe?g|png|gif|webp)$", low):
return None
return s
def score_candidate(filename: str, caption: str) -> int:
"""Higher is better. Score <= 0 means EN-only / reject for UnnumberedPromo."""
fl = filename.lower().replace(" ", "").replace("_", "")
cl = caption.lower()
score = 0
if any(m in cl for m in JP_CAPTION_MARKERS):
score += 100
if any(m.replace(" ", "") in fl for m in JP_FILENAME_MARKERS):
score += 50
if "promo" in fl and not any(m in fl for m in ("wizardspromo", "nintendopromo", "dppromo")):
score += 10
en_hit = any(m.replace(" ", "") in fl for m in EN_FILENAME_MARKERS)
if en_hit:
# EN primary unless caption explicitly marks Unnumbered/JP.
if score < 100:
return -100
score -= 20
return score
def collect_image_candidates(wikitext: str) -> list[tuple[int, str]]:
"""Return (score, filename) for JP-eligible images, best first."""
# Map field key -> value for pairing imageN with captionN / reprintN with recaptionN.
fields: dict[str, str] = {}
for m in FIELD_RE.finditer(wikitext):
kind = m.group(1).lower()
num = m.group(2) or ""
val = m.group(3).strip()
fields[f"{kind}{num}"] = val
candidates: list[tuple[int, str]] = []
seen: set[str] = set()
def add(fname_raw: str, caption: str) -> None:
fname = normalize_filename(fname_raw)
if not fname:
return
key = fname.lower().replace(" ", "_")
if key in seen:
return
score = score_candidate(fname, caption)
if score <= 0:
return
seen.add(key)
candidates.append((score, fname))
# Primary image + caption (usually EN — only kept if JP-scored).
if "image" in fields:
add(fields["image"], fields.get("caption", ""))
# reprintN + recaptionN (common home of Unnumbered JP scans).
for key, val in list(fields.items()):
m = re.fullmatch(r"reprint(\d+)", key)
if not m:
continue
n = m.group(1)
add(val, fields.get(f"recaption{n}", "") or fields.get(f"caption{n}", ""))
# Gallery imageN + captionN.
for key, val in list(fields.items()):
m = re.fullmatch(r"image(\d+)", key)
if not m:
continue
n = m.group(1)
add(val, fields.get(f"caption{n}", "") or fields.get(f"recaption{n}", ""))
candidates.sort(key=lambda t: (-t[0], t[1].lower()))
return candidates
def normalize_token_blob(s: str) -> str:
"""Lowercase alnum-only blob for substring affinity checks."""
return re.sub(r"[^a-z0-9]+", "", s.lower())
def identity_tokens(print_row: dict) -> list[str]:
"""Significant tokens from this print's promo identity (set / page)."""
raw_bits: list[str] = []
for key in ("tcg_set", "bulbapedia_page", "name_en"):
val = str(print_row.get(key) or "").strip()
if val:
raw_bits.append(val)
# Prefer longer set-like phrases first.
tokens: list[str] = []
for bit in raw_bits:
# Drop trailing extras like "(Jumbo)".
bit = re.sub(r"\s*\([^)]*(?:Jumbo|Mini|Silver|Gold)[^)]*\)\s*", " ", bit)
# Pull parenthetical set qualifier: "Mewtwo (WHF Special Sheet promo)".
m = re.search(r"\(([^)]+)\)", bit)
if m:
inner = m.group(1)
inner = re.sub(r"\bpromo\b", "", inner, flags=re.I).strip()
if inner:
tokens.append(inner)
tokens.append(bit)
# Significant wordy tokens (>=3 chars after normalize), longest first.
out: list[str] = []
seen: set[str] = set()
for t in tokens:
norm = normalize_token_blob(t)
if len(norm) < 4:
continue
if norm in seen:
continue
# Skip generic card-name-only blobs when we have set context.
seen.add(norm)
out.append(norm)
out.sort(key=len, reverse=True)
return out
def has_print_affinity(
print_row: dict,
requested_page: str,
resolved_title: str,
filename: str,
caption: str = "",
) -> bool:
"""True if this JP candidate belongs to this print, not a borrowed promo."""
tokens = identity_tokens(print_row)
token_set = set(tokens)
hay = normalize_token_blob(filename + " " + caption + " " + resolved_title)
fl = normalize_token_blob(filename)
req = normalize_token_blob(requested_page)
resolved = normalize_token_blob(resolved_title)
# Filename names a specific JP promo family this print is not part of → reject.
foreign_markers = (
"whf",
"corocoro",
"fanbook",
"gb2",
"specialsheet",
"songbest",
"battleroad",
"teamgr",
"illustrator",
"asobikata",
"vending",
"movie",
)
for marker in foreign_markers:
if marker in fl and not any(marker in tok for tok in token_set):
# e.g. WHF file on a Wizards Promo / Song Best Collection row.
return False
# Resolved title still matches what we asked for (allow mild redirect rename).
if req and (req in resolved or resolved in req):
# Still require filename not foreign (handled above); OK.
if any(m in fl for m in foreign_markers) or "unnumbered" in hay or any(
len(tok) >= 5 and tok in fl for tok in token_set
):
return True
# Requested page matched but image is generic EN — leave to score_candidate.
if any(len(tok) >= 5 and tok in hay for tok in token_set):
return True
tcg_set = str(print_row.get("tcg_set") or "").strip()
tcg_set_norm = normalize_token_blob(tcg_set)
# Wizards Promo rows may use the Wizards article, but only with a
# non-foreign JP file (foreign_markers already rejected WHF/etc.).
if tcg_set_norm.startswith("wizardspromo") and "wizardspromo" in resolved:
if "wizardspromo" in fl or (
any(m in caption.lower() for m in JP_CAPTION_MARKERS)
and not any(m in fl for m in foreign_markers)
):
return True
return False
# Reject borrowing from a generic Wizards Promo dump unless this print is that set.
if "wizardspromo" in resolved and not tcg_set_norm.startswith("wizardspromo"):
for tok in tokens:
if len(tok) >= 5 and tok in hay and "wizardspromo" not in tok:
species = normalize_token_blob(str(print_row.get("tcg_name") or ""))
if species and tok == species:
continue
return True
return False
for tok in tokens:
if len(tok) >= 5 and tok in hay:
species = normalize_token_blob(str(print_row.get("tcg_name") or ""))
if species and tok == species:
continue
return True
if len(tok) >= 3 and tok in ("whf", "gb2") and tok in hay:
return True
for tok in tokens:
if len(tok) >= 5 and tok in fl:
species = normalize_token_blob(str(print_row.get("tcg_name") or ""))
if species and tok == species:
continue
return True
return False
def pick_image_filename_for_print(
print_row: dict,
requested_page: str,
resolved_title: str,
wikitext: str,
) -> str | None:
"""Best JP scan that also has affinity with this print's promo identity."""
fields: dict[str, str] = {}
for m in FIELD_RE.finditer(wikitext):
fields[f"{m.group(1).lower()}{m.group(2) or ''}"] = m.group(3).strip()
def caption_for(fname: str) -> str:
target = fname.lower().replace(" ", "_")
for key, val in fields.items():
nf = normalize_filename(val)
if not nf or nf.lower().replace(" ", "_") != target:
continue
if key == "image":
return fields.get("caption", "")
m = re.fullmatch(r"(reprint|image)(\d+)", key)
if not m:
continue
n = m.group(2)
if m.group(1) == "reprint":
return fields.get(f"recaption{n}", "") or fields.get(f"caption{n}", "")
return fields.get(f"caption{n}", "") or fields.get(f"recaption{n}", "")
return ""
for _score, fname in collect_image_candidates(wikitext):
if has_print_affinity(
print_row, requested_page, resolved_title, fname, caption_for(fname)
):
return fname
return None
def pick_jname(wikitext: str) -> str:
m = JNAME_RE.search(wikitext)
if not m:
return ""
return m.group(1).strip()
def candidate_pages(print_row: dict) -> list[str]:
"""Qualified Bulbapedia titles only — never bare species (avoids shared dumps)."""
out: list[str] = []
page = str(print_row.get("bulbapedia_page") or "").strip()
if page:
out.append(page)
tcg_set = str(print_row.get("tcg_set") or "").strip()
tcg_name = str(print_row.get("tcg_name") or "").strip()
tcg_num = str(print_row.get("tcg_num") or "").strip()
if tcg_name and tcg_set:
if not tcg_num or tcg_num.lower() == "promo":
out.append(f"{tcg_name} ({tcg_set} promo)")
else:
out.append(f"{tcg_name} ({tcg_set} {tcg_num})")
out.append(f"{tcg_name} ({tcg_set} promo)")
# Full qualified English title from harvest (may include Jumbo markers).
name_en = str(print_row.get("name_en") or "").strip()
if name_en and "(" in name_en:
# Strip only trailing variant markers, keep set qualifier.
cleaned = re.sub(
r"\s*\((?:Jumbo|Mini|Silver|Gold|Silver w/Stamp)[^)]*\)\s*$",
"",
name_en,
flags=re.I,
).strip()
if cleaned:
out.append(cleaned)
out.append(name_en)
seen: set[str] = set()
uniq: list[str] = []
for p in out:
if p and p not in seen:
seen.add(p)
uniq.append(p)
return uniq
def enrich_print(print_row: dict, sleep_s: float) -> bool:
"""Mutate print_row with JP image_url / name_ja. Return True if image filled."""
already = str(print_row.get("image_url") or "").strip()
if already:
return False
for page in candidate_pages(print_row):
time.sleep(sleep_s)
resolved = fetch_wikitext(page)
if not resolved:
continue
title, wt = resolved
if not str(print_row.get("name_ja") or "").strip():
jname = pick_jname(wt)
if jname:
print_row["name_ja"] = jname
fname = pick_image_filename_for_print(print_row, page, title, wt)
if not fname:
continue
time.sleep(sleep_s)
url = file_url(fname)
if url:
print_row["image_url"] = url
return True
return False
def log(msg: str) -> None:
try:
print(msg)
except UnicodeEncodeError:
print(msg.encode("ascii", errors="replace").decode("ascii"))
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--limit", type=int, default=0, help="Max UnnumberedPromo rows")
ap.add_argument("--sleep", type=float, default=0.35, help="Seconds between API calls")
ap.add_argument("--force", action="store_true", help="Overwrite existing image_url")
ap.add_argument(
"--save-every",
type=int,
default=25,
help="Persist classic_missing_prints.json every N updates",
)
args = ap.parse_args()
all_prints: list[dict] = json.loads(PRINTS.read_text(encoding="utf-8"))
targets = [p for p in all_prints if str(p.get("set_id")) == SET_ID]
if args.limit > 0:
targets = targets[: args.limit]
def persist() -> None:
if args.dry_run:
return
PRINTS.write_text(
json.dumps(all_prints, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
filled = 0
missed = 0
updates_since_save = 0
for i, p in enumerate(targets, start=1):
lid = p.get("local_id")
name = p.get("name_en")
if args.force:
p.pop("image_url", None)
if str(p.get("image_url") or "").strip():
log(f"[{i}/{len(targets)}] skip {lid} {name} (already has image)")
continue
ok = enrich_print(p, sleep_s=args.sleep)
if ok:
filled += 1
updates_since_save += 1
log(f"[{i}/{len(targets)}] OK {lid} {name} -> {p.get('image_url')}")
else:
missed += 1
# Ensure stale EN URLs do not linger after --force.
p.pop("image_url", None)
log(f"[{i}/{len(targets)}] MISS {lid} {name}")
if updates_since_save >= args.save_every:
persist()
updates_since_save = 0
log(f" checkpoint wrote {PRINTS}")
log(f"filled={filled} missed={missed} total={len(targets)}")
if args.dry_run:
log(f"dry-run: not writing {PRINTS}")
return
persist()
log(f"wrote {PRINTS}")
if __name__ == "__main__":
main()
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Download and convert bundled classic Japanese gym-deck scans.
Writes JPEGs under ui_wx/assets/pokemon_jp_classic/<setId>/<localId>.jpg.
Uses dwebp + cjpeg from the local MSYS2 toolchain so we do not need Pillow.
"""
from __future__ import annotations
import shutil
import subprocess
import tempfile
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
OUT_DIR = ROOT / "ui_wx" / "assets" / "pokemon_jp_classic"
# Curated direct CDN/file URLs for printing-accurate deck scans.
SOURCES: dict[tuple[str, str], str] = {
# TCGCollector static CDN URL for Erika (City Gym Decks No. 061).
("TamamushiCG", "016"): (
"https://static.tcgcollector.com/content/images/9d/33/c6/"
"9d33c6ffe701da03266dd5a65c6ee9537c7043b63b880cf3889f644e1c66aa6f.webp"
),
}
def tool(name: str) -> str:
path = shutil.which(name)
if path is None:
raise SystemExit(f"required tool not found on PATH: {name}")
return path
def download(url: str, dest: Path) -> None:
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=60) as resp:
dest.write_bytes(resp.read())
def convert_to_jpeg(src: Path, dest: Path) -> None:
suffix = src.suffix.lower()
if suffix in {".jpg", ".jpeg"}:
shutil.copyfile(src, dest)
return
if suffix == ".webp":
dwebp = tool("dwebp")
cjpeg = tool("cjpeg")
with tempfile.NamedTemporaryFile(suffix=".ppm", delete=False) as tmp:
ppm = Path(tmp.name)
try:
subprocess.run([dwebp, str(src), "-ppm", "-o", str(ppm)], check=True)
with open(dest, "wb") as out:
subprocess.run([cjpeg, "-quality", "92", str(ppm)], check=True, stdout=out)
finally:
ppm.unlink(missing_ok=True)
return
raise SystemExit(f"unsupported source format: {src}")
def main() -> None:
for (set_id, local_id), url in SOURCES.items():
out_dir = OUT_DIR / set_id
out_dir.mkdir(parents=True, exist_ok=True)
target = out_dir / f"{local_id}.jpg"
with tempfile.TemporaryDirectory() as td:
src = Path(td) / Path(url).name
print(f"download {set_id}/{local_id} <- {url}")
download(url, src)
convert_to_jpeg(src, target)
print(f"wrote {target}")
if __name__ == "__main__":
main()
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Build English set names for Japanese Pokémon catalog from Serebii + TCGdex."""
from __future__ import annotations
import json
import re
import urllib.request
from html import unescape
from pathlib import Path
UA = "CardCollectionManager3-ETL/0.1"
OUT = Path("ui_wx/assets/pokemon_jp_en_catalog.json")
SEREBII = "https://www.serebii.net/card/japanese.shtml"
JA_OVERRIDES = {"SV4a": "シャイニートレジャーex"}
def get_bytes(url: str) -> bytes:
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=60) as resp:
return resp.read()
def contains_cjk(s: str) -> bool:
return any(ord(c) >= 0x80 for c in s)
def parse_serebii_ja_en(html: str) -> dict[str, str]:
"""Extract Japanese -> English set name pairs from Serebii japanese.shtml."""
ja_en: dict[str, str] = {}
# Common patterns on the page:
# English Name<br>Japanese
# English Name (Japanese)
# <a ...>English</a> ... Japanese in nearby cell
for m in re.finditer(
r">([A-Za-z0-9][^<]{1,70}?)</(?:a|b|font|td|span)>\s*<br\s*/?>\s*"
r"([^<]{2,50}?)<",
html,
flags=re.IGNORECASE,
):
en = unescape(re.sub(r"\s+", " ", m.group(1))).strip()
ja = unescape(re.sub(r"\s+", " ", m.group(2))).strip()
if contains_cjk(ja) and not contains_cjk(en) and len(en) > 1:
ja_en.setdefault(ja, en)
for m in re.finditer(
r">([A-Za-z0-9][^<]{1,70}?)\s*\(([^)]{2,50})\)<",
html,
):
en = unescape(re.sub(r"\s+", " ", m.group(1))).strip()
ja = unescape(re.sub(r"\s+", " ", m.group(2))).strip()
if contains_cjk(ja) and not contains_cjk(en) and len(en) > 1:
ja_en.setdefault(ja, en)
return ja_en
def main() -> None:
sets = [
s
for s in json.loads(get_bytes("https://api.tcgdex.net/v2/ja/sets"))
if not str(s.get("id", "")).startswith("CS")
]
print("tcgdex sets", len(sets))
html = get_bytes(SEREBII).decode("utf-8", "replace")
Path("tools/pokemon_jp/_serebii_japanese.html").write_text(html, encoding="utf-8")
print("serebii bytes", len(html))
ja_en = parse_serebii_ja_en(html)
print("ja->en pairs", len(ja_en))
for ja, en in list(ja_en.items())[:12]:
print(f" {en!r} <- {ja!r}")
prints = []
if OUT.exists():
try:
prints = json.loads(OUT.read_text(encoding="utf-8")).get("prints", [])
except Exception:
pass
catalog: dict[str, dict] = {}
matched = 0
for entry in sets:
sid = entry["id"]
name_ja = JA_OVERRIDES.get(sid, entry.get("name", ""))
name_en = ja_en.get(name_ja, "")
if not name_en:
for ja, en in ja_en.items():
if ja == name_ja or ja in name_ja or name_ja in ja:
name_en = en
break
if name_en:
matched += 1
else:
name_en = sid
catalog[sid] = {
"name_en": name_en,
"name_ja": name_ja,
"releaseDate": "",
}
print(f"matched {matched}/{len(sets)}; id fallback {len(sets) - matched}")
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(
json.dumps({"sets": catalog, "prints": prints}, ensure_ascii=False, indent=2)
+ "\n",
encoding="utf-8",
)
print("wrote", OUT)
if __name__ == "__main__":
main()
@@ -0,0 +1,239 @@
#!/usr/bin/env python3
"""Harvest Bulbapedia Unnumbered Promotional cards into classic_missing seed JSON.
Fetches:
- Unnumbered_Promotional_cards_(TCG)/1996-2005
- Yearly sections on Unnumbered_Promotional_cards_(TCG) (2006+)
Writes/updates:
- tools/pokemon_jp/classic_missing_sets.json (adds UnnumberedPromo)
- tools/pokemon_jp/classic_missing_prints.json (replaces UnnumberedPromo prints)
Synthetic local_ids are sequential 001… (cards are unnumbered in print).
Each print carries a qualified English title when the setlist row has a
{{TCG ID|Set|Name|num}} (e.g. "Mewtwo (CoroCoro promo)") plus a
`bulbapedia_page` hint for enrich_unnumbered_promo_images.py.
Run after harvest:
python tools/pokemon_jp/enrich_unnumbered_promo_images.py
python tools/pokemon_jp/merge_classic_missing.py
"""
from __future__ import annotations
import json
import re
import urllib.parse
import urllib.request
from pathlib import Path
HERE = Path(__file__).resolve().parent
SETS = HERE / "classic_missing_sets.json"
PRINTS = HERE / "classic_missing_prints.json"
SET_ID = "UnnumberedPromo"
SET_META = {
"name_en": "Unnumbered Promotional cards",
"name_ja": "番号なしプロモーションカード",
"releaseDate": "1997/03/06",
}
UA = "CCM3-pokemon-jp-etl/1.0 (local; +https://github.com/sebastiandine/Card-Collection-Manager-3)"
# {{TCG ID|Set|Name|num}} — name may contain δ / &amp; etc.
TCG_ID_FULL_RE = re.compile(
r"\{\{TCG ID\|([^}|]+)\|([^}|]+)(?:\|([^}|]*))?\}\}", re.IGNORECASE
)
TCG_RE = re.compile(r"\{\{TCG\|([^}|]+)(?:\|[^}]*)?\}\}", re.IGNORECASE)
OBP_RE = re.compile(r"\{\{OBP\|([^}|]+)(?:\|[^}]*)?\}\}", re.IGNORECASE)
SMALL_TAG_RE = re.compile(
r"<small>\s*'''?\s*\[([^\]]+)\]\s*'''?\s*</small>", re.IGNORECASE
)
ITALIC_NOTE_RE = re.compile(r"\(''([^']+)''\)")
HTML_TAG_RE = re.compile(r"<[^>]+>")
TEMPLATE_RE = re.compile(r"\{\{[^{}]*\}\}")
def fetch_wikitext(page: str) -> str:
qs = urllib.parse.urlencode(
{
"action": "parse",
"page": page,
"prop": "wikitext",
"format": "json",
}
)
url = f"https://bulbapedia.bulbagarden.net/w/api.php?{qs}"
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=90) as resp:
data = json.load(resp)
return data["parse"]["wikitext"]["*"]
def decode_wiki_text(s: str) -> str:
return (
s.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&#39;", "'")
.strip()
)
def bulbapedia_page_from_tcg_id(set_name: str, card_name: str, num: str) -> str:
"""Best-effort Bulbapedia article title for a TCG ID triple."""
set_name = decode_wiki_text(set_name)
card_name = decode_wiki_text(card_name)
num = decode_wiki_text(num or "").strip()
if not num or num.lower() == "promo":
return f"{card_name} ({set_name} promo)"
return f"{card_name} ({set_name} {num})"
def qualified_name_en(card_name: str, set_name: str, num: str, extras: list[str]) -> str:
"""Distinct English title: Name (Set promo) plus optional [Jumbo]/ markers."""
card_name = decode_wiki_text(card_name)
set_name = decode_wiki_text(set_name)
num = decode_wiki_text(num or "").strip()
if set_name:
if not num or num.lower() == "promo":
base = f"{card_name} ({set_name} promo)"
else:
base = f"{card_name} ({set_name} {num})"
else:
base = card_name
# Avoid duplicating qualifier already present in extras.
remaining = [
e
for e in extras
if e.lower() not in base.lower() and e.lower() not in {"promo"}
]
if remaining:
return f"{base} ({'; '.join(remaining)})"
return base
def collect_extras(raw: str) -> list[str]:
extras: list[str] = []
for m in SMALL_TAG_RE.finditer(raw):
extras.append(m.group(1).strip())
for m in ITALIC_NOTE_RE.finditer(raw):
extras.append(m.group(1).strip())
if "Jumbo" in raw and not any("jumbo" in e.lower() for e in extras):
extras.append("Jumbo")
if "Mini" in raw and not any("mini" in e.lower() for e in extras):
extras.append("Mini")
return extras
def extract_entry(field: str) -> dict | None:
"""Parse one Setlist/nmentry name field into print metadata."""
raw = field.strip()
extras = collect_extras(raw)
tcg = TCG_ID_FULL_RE.search(raw)
if tcg:
set_name = tcg.group(1).strip()
card_name = tcg.group(2).strip()
num = (tcg.group(3) or "").strip()
name_en = qualified_name_en(card_name, set_name, num, extras)
page = bulbapedia_page_from_tcg_id(set_name, card_name, num)
return {
"name_en": name_en,
"bulbapedia_page": page,
"tcg_set": decode_wiki_text(set_name),
"tcg_name": decode_wiki_text(card_name),
"tcg_num": decode_wiki_text(num) if num else "promo",
}
name = None
for rx in (TCG_RE, OBP_RE):
m = rx.search(raw)
if m:
name = decode_wiki_text(m.group(1))
break
if not name:
cleaned = TEMPLATE_RE.sub("", raw)
cleaned = HTML_TAG_RE.sub("", cleaned)
cleaned = cleaned.split("|", 1)[0].strip()
cleaned = re.sub(r"\[\[([^|\]]+)(?:\|[^\]]+)?\]\]", r"\1", cleaned)
name = decode_wiki_text(cleaned.strip(" '\""))
if not name:
return None
if extras:
name = f"{name} ({'; '.join(extras)})"
return {"name_en": name, "bulbapedia_page": name}
def harvest_entries(wikitext: str) -> list[dict]:
entries: list[dict] = []
for m in re.finditer(r"\{\{Setlist/nmentry\|None\|", wikitext):
start = m.end()
depth = 0
i = start
while i < len(wikitext):
if wikitext.startswith("{{", i):
depth += 1
i += 2
continue
if wikitext.startswith("}}", i):
depth = max(0, depth - 1)
i += 2
continue
if wikitext[i] == "|" and depth == 0:
break
i += 1
parsed = extract_entry(wikitext[start:i])
if parsed:
entries.append(parsed)
return entries
def main() -> None:
pages = [
"Unnumbered_Promotional_cards_(TCG)/1996-2005",
"Unnumbered_Promotional_cards_(TCG)",
]
all_entries: list[dict] = []
for page in pages:
wt = fetch_wikitext(page)
got = harvest_entries(wt)
print(f"{page}: {len(got)} setlist rows")
all_entries.extend(got)
prints: list[dict] = []
for i, entry in enumerate(all_entries, start=1):
row = {
"set_id": SET_ID,
"local_id": f"{i:03d}",
"name_en": entry["name_en"],
"name_ja": "",
"name_en_source": "manual",
"bulbapedia_page": entry.get("bulbapedia_page") or entry["name_en"],
}
for key in ("tcg_set", "tcg_name", "tcg_num"):
if entry.get(key):
row[key] = entry[key]
prints.append(row)
sets_obj: dict = json.loads(SETS.read_text(encoding="utf-8"))
sets_obj[SET_ID] = SET_META
SETS.write_text(
json.dumps(sets_obj, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
existing: list[dict] = json.loads(PRINTS.read_text(encoding="utf-8"))
kept = [p for p in existing if str(p.get("set_id")) != SET_ID]
kept.extend(prints)
PRINTS.write_text(
json.dumps(kept, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
print(
f"wrote {SET_ID}: {len(prints)} prints "
f"(classic_missing_prints total={len(kept)})"
)
if __name__ == "__main__":
main()
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Merge classic TCGdex-missing JA products into pokemon_jp_en_catalog.json.
Reads:
tools/pokemon_jp/classic_missing_sets.json
tools/pokemon_jp/classic_missing_prints.json
tools/pokemon_jp/set_en_names.json (updated with EN display names)
Writes set metadata + prints into ui_wx/assets/pokemon_jp_en_catalog.json
without dropping existing TCGdex-backed entries. Replaces prior prints for
the same (set_id, local_id) keys from classic_missing_prints.json.
"""
from __future__ import annotations
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
HERE = Path(__file__).resolve().parent
SETS = HERE / "classic_missing_sets.json"
PRINTS = HERE / "classic_missing_prints.json"
EN_MAP = HERE / "set_en_names.json"
OUT = ROOT / "ui_wx" / "assets" / "pokemon_jp_en_catalog.json"
# Classic gym-deck / sheet products use synthetic set ids (not on TCGdex).
CLASSIC_SET_IDS = frozenset(
{
"UnnumberedPromo",
"ExpSheet1",
"ExpSheet2",
"ExpSheet3",
"NiviCG",
"HanadaCG",
"KuchibaCG",
"TamamushiCG",
"YamabukiCG",
"GurenTG",
"SouthernIslands",
}
)
GYM_DECK_SET_IDS = frozenset(
{"NiviCG", "HanadaCG", "KuchibaCG", "TamamushiCG", "YamabukiCG", "GurenTG"}
)
def strip_gym_deck_donor_ids(classic_prints: list[dict]) -> int:
"""Remove PMCG donor ids; gym-deck exclusives need printing-accurate art."""
stripped = 0
for p in classic_prints:
sid = str(p.get("set_id") or "")
if sid not in GYM_DECK_SET_IDS:
continue
if p.get("image_url"):
p.pop("tcgplayer_id", None)
continue
if p.pop("tcgplayer_id", None) is not None:
stripped += 1
return stripped
def main() -> None:
missing_sets: dict[str, dict] = json.loads(SETS.read_text(encoding="utf-8"))
missing_prints: list[dict] = json.loads(PRINTS.read_text(encoding="utf-8"))
en_map: dict[str, str] = json.loads(EN_MAP.read_text(encoding="utf-8"))
catalog: dict = {"sets": {}, "prints": []}
if OUT.exists():
catalog = json.loads(OUT.read_text(encoding="utf-8"))
sets_obj: dict = catalog.setdefault("sets", {})
for sid, meta in missing_sets.items():
name_en = str(meta.get("name_en") or "").strip() or sid
en_map[sid] = name_en
sets_obj[sid] = {
"name_en": name_en,
"name_ja": str(meta.get("name_ja") or ""),
"releaseDate": str(meta.get("releaseDate") or ""),
}
EN_MAP.write_text(
json.dumps(dict(sorted(en_map.items())), ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
classic_ids = {s for s in missing_sets}
classic_keys = {
(str(p.get("set_id")), str(p.get("local_id"))) for p in missing_prints
}
existing: list[dict] = catalog.get("prints") or []
kept = [
p
for p in existing
if (str(p.get("set_id")), str(p.get("local_id"))) not in classic_keys
or str(p.get("set_id")) not in classic_ids
]
# Drop all prints for classic set ids, then append the curated list.
kept = [p for p in kept if str(p.get("set_id")) not in classic_ids]
stripped = strip_gym_deck_donor_ids(missing_prints)
kept.extend(missing_prints)
catalog["prints"] = kept
OUT.write_text(
json.dumps(catalog, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
print(
f"merged {len(missing_sets)} classic sets and {len(missing_prints)} prints "
f"into {OUT} (total prints={len(kept)}, gym donor ids stripped={stripped})"
)
if __name__ == "__main__":
main()
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Merge curated English set names into pokemon_jp_en_catalog.json.
Reads tools/pokemon_jp/_tcgdex_sets.json (TCGdex JA list snapshot) and
tools/pokemon_jp/set_en_names.json (curated id -> English display name).
Also preserves / refreshes classic TCGdex-missing products from
classic_missing_sets.json (UnnumberedPromo, City Gym decks, Expansion Sheets,
Southern Islands).
Never writes Japanese into name_en.
"""
from __future__ import annotations
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
HERE = Path(__file__).resolve().parent
SETS_SNAP = HERE / "_tcgdex_sets.json"
EN_MAP = HERE / "set_en_names.json"
CLASSIC = HERE / "classic_missing_sets.json"
OUT = ROOT / "ui_wx" / "assets" / "pokemon_jp_en_catalog.json"
JA_OVERRIDES = {"SV4a": "シャイニートレジャーex"}
def main() -> None:
sets = json.loads(SETS_SNAP.read_text(encoding="utf-8"))
en_map: dict[str, str] = json.loads(EN_MAP.read_text(encoding="utf-8"))
classic: dict[str, dict] = {}
if CLASSIC.exists():
classic = json.loads(CLASSIC.read_text(encoding="utf-8"))
prints = []
if OUT.exists():
try:
prints = json.loads(OUT.read_text(encoding="utf-8")).get("prints", [])
except Exception:
pass
catalog: dict[str, dict] = {}
matched = 0
for entry in sets:
sid = entry["id"]
if sid.startswith("CS"):
continue
name_ja = JA_OVERRIDES.get(sid, entry.get("name", ""))
name_en = en_map.get(sid, "").strip()
if name_en:
matched += 1
else:
name_en = sid
catalog[sid] = {
"name_en": name_en,
"name_ja": name_ja,
"releaseDate": "",
}
for sid, meta in classic.items():
name_en = str(meta.get("name_en") or en_map.get(sid) or sid).strip()
catalog[sid] = {
"name_en": name_en,
"name_ja": str(meta.get("name_ja") or ""),
"releaseDate": str(meta.get("releaseDate") or ""),
}
if name_en and name_en != sid:
matched += 1
OUT.write_text(
json.dumps({"sets": catalog, "prints": prints}, ensure_ascii=False, indent=2)
+ "\n",
encoding="utf-8",
)
print(f"wrote {OUT}: {matched}/{len(catalog)} curated EN names")
if __name__ == "__main__":
main()
+263
View File
@@ -0,0 +1,263 @@
{
"Bugsyのテクニカルマシン01": "Bugsy's Technical Machine 01",
"Bugsyのテクニカルマシン02": "Bugsy's Technical Machine 02",
"Clair's Technical Machine 01": "Clair's Technical Machine 01",
"Clair's Technical Machine 02": "Clair's Technical Machine 02",
"Max Revive": "Max Revive",
"Moomooミルク": "Moomoo Milk",
"Morty's Technical Machine 01": "Morty's Technical Machine 01",
"Morty's Technical Machine 02": "Morty's Technical Machine 02",
"ecogym": "Eco Gym",
"exp。共有": "EXP. Share",
"いいきずぐすり": "Super Potion",
"お上品攻撃": "Refined Attack",
"きずぐすり": "Potion",
"きずぐすり配合エネルギー": "Potion Energy",
"くすぐりマシーン": "Tickling Machine",
"げんきのかけら": "Revive",
"せまいジム": "Narrow Gym",
"たたきつけろ!挑戦状": "Challenge!",
"なかよしポフィン": "Buddy-Buddy Poffin",
"なにかの化石": "Mysterious Fossil",
"なんでもなおし": "Full Heal",
"なんでもなおし配合エネルギー": "Full Heal Energy",
"にせオーキドの逆襲": "Impostor Professor Oak's Revenge",
"ねむれ!ねむれ!": "Sleep! Sleep!",
"ふうせん": "Air Balloon",
"ふしぎなアメ": "Rare Candy",
"まきちらせ!ベトベトガス": "Goop Gas Attack",
"アカマツ": "Carmine",
"アプリコーンの森": "Apricorn Forest",
"アーケードゲーム": "Arcade Game",
"ウィルのテクニカルマシン01": "Will's Technical Machine 01",
"ウィルのテクニカルマシン02": "Will's Technical Machine 02",
"ウォーターキューブ01": "Water Cube 01",
"エネルギーの流れ": "Energy Flow",
"エネルギーをリサイクルします": "Recycle Energy",
"エネルギーを高めます": "Boost Energy",
"エネルギーアーク": "Energy Ark",
"エネルギーサーキュレート": "Energy Circulate",
"エネルギースイッチ": "Energy Switch",
"エネルギースタジアム": "Energy Stadium",
"エネルギー・リムーブ": "Energy Removal",
"エネルギー回収": "Energy Retrieval",
"エネルギー回復": "Energy Restore",
"エネルギー増幅器": "Energy Amplifier",
"エネルギー検索": "Energy Search",
"エネルギー転送": "Energy Transfer",
"エネルギー除去2": "Energy Removal 2",
"エネルギー電荷": "Energy Charge",
"エリカ": "Erika",
"エリカのお付き": "Erika's Maids",
"エリカの親切": "Erika's Kindness",
"エリカの香水": "Erika's Perfume",
"エルム教授": "Professor Elm",
"エルム教授のトレーニング方法": "Professor Elm's Training Method",
"オーキドはかせ": "Professor Oak",
"オーク教授の研究": "Professor Oak's Research",
"カウンターゲイン": "Counter Gain",
"カスミ": "Misty",
"カスミのいかり": "Misty's Wrath",
"カスミのなみだ": "Misty's Tears",
"カスミのわがまま": "Misty's Wish",
"カスミの勝負": "Misty's Duel",
"カツラ": "Blaine",
"カツラのギャンブル": "Blaine's Gamble",
"カツラのクイズ その3": "Blaine's Quiz #3",
"カツラの奥の手": "Blaine's Last Resort",
"カレンのテクニカルマシン01": "Karen's Technical Machine 01",
"カレンのテクニカルマシン02": "Karen's Technical Machine 02",
"カードフリップゲーム": "Card Flip Game",
"キョウ": "Koga",
"キョウ秘伝, 変わり身の術": "Koga's Ninja Trick",
"ギャンブラー": "Gambler",
"クイックボール": "Quick Ball",
"クチバシティジム": "Vermilion City Gym",
"クリスタルエネルギー": "Crystal Energy",
"グッドマナー": "Good Manners",
"グラスキューブ01": "Grass Cube 01",
"グレンタウンジム": "Cinnabar Island Gym",
"ゴールドベリー": "Gold Berry",
"サイキックキューブ01": "Psychic Cube 01",
"サカキ": "Giovanni",
"サカキの切り札": "Giovanni's Last Resort",
"ジャグラー": "Juggler",
"ジャスミンのテクニカルマシン01": "Jasmine's Technical Machine 01",
"ジャスミンのテクニカルマシン02": "Jasmine's Technical Machine 02",
"ジャニーンのテクニカルマシン01": "Janine's Technical Machine 01",
"ジャニーンのテクニカルマシン02": "Janine's Technical Machine 02",
"スイッチ": "Switch",
"スイレンのお世話": "Lana's Aid",
"スパイ作戦": "Spy Work",
"スプラウトタワー": "Sprout Tower",
"スーパーエネルギー検索": "Super Energy Retrieval",
"スーパーエネルギー除去2": "Super Energy Removal 2",
"スーパースクープアップ": "Super Scoop Up",
"スーパーボール": "Great Ball",
"スーパーロッド": "Super Rod",
"セキチクシティジム": "Fuchsia City Gym",
"タイムカプセル": "Time Capsule",
"タイムシャード": "Time Shard",
"タケシ": "Brock",
"タケシの保護": "Brock's Protection",
"タケシの育て方": "Brock's Training Method",
"タマムシシティジム": "Celadon City Gym",
"ダウジングマシーン": "Item Finder",
"ダブル無色エネルギー": "Double Colorless Energy",
"チャックのテクニカルマシン01": "Chuck's Technical Machine 01",
"チャックのテクニカルマシン02": "Chuck's Technical Machine 02",
"チャリティ": "Charity",
"チームロケットの邪悪な行為": "Team Rocket's Evil Deeds",
"テクノレーダー": "Technical Machine: Evolution",
"ディフェンダー": "Defender",
"デュアルボール": "Dual Ball",
"トウコ": "Hilda",
"トキワシティジム": "Viridian City Gym",
"トラッシュ交換": "Trash Exchange",
"ナツメ": "Sabrina",
"ナツメのESP": "Sabrina's ESP",
"ナツメのサイキックコントロール": "Sabrina's Psychic Control",
"ナツメの眼": "Sabrina's Gaze",
"ナンジャモ": "Iono",
"ニビシティジム": "Pewter City Gym",
"ネストボール": "Nest Ball",
"ハイパーデボルブスプレー": "Hyper Devolution Spray",
"ハイパーボール": "Ultra Ball",
"ハナダシティジム": "Cerulean City Gym",
"バトルVIPパス": "Battle VIP Pass",
"バトル場は穴だらけ!": "The Field is Full of Holes!",
"バルーンベリー": "Balloon Berry",
"パソコン通信": "Computer Search",
"パワープロテイン": "Power Protein",
"ヒーリングフィールド": "Healing Field",
"ビルからのメール": "Mail from Bill",
"ビルのテレポーター": "Bill's Teleporter",
"ビルのメンテナンス": "Bill's Maintenance",
"ピッピ人形": "Clefairy Doll",
"ファイアキューブ01": "Fire Cube 01",
"フォーカスバンド": "Focus Band",
"フォークナーのテクニカルマシン01": "Falkner's Technical Machine 01",
"フォークナーのテクニカルマシン02": "Falkner's Technical Machine 02",
"フジろうじん": "Mr. Fuji",
"フルヒール": "Full Heal",
"ブルーノのテクニカルマシン01": "Bruno's Technical Machine 01",
"ブルーノのテクニカルマシン02": "Bruno's Technical Machine 02",
"ブレイブバングル": "Brave Bangle",
"プライスのテクニカルマシン01": "Pryce's Technical Machine 01",
"プライスのテクニカルマシン02": "Pryce's Technical Machine 02",
"プライムキャッチャー": "Prime Catcher",
"プラスパワー": "PlusPower",
"ベリー": "Berry",
"ペパー": "Arven",
"ホイットニーのテクニカルマシン01": "Whitney's Technical Machine 01",
"ホイットニーのテクニカルマシン02": "Whitney's Technical Machine 02",
"ボスのやりかた": "The Boss's Way",
"ボスの指令": "Boss's Orders",
"ポクギア": "Pokégear",
"ポケギア3.0": "Pokégear 3.0",
"ポケモンいれかえ": "Switch",
"ポケモンの笛": "Pokémon Flute",
"ポケモンキャッチャー": "Pokémon Catcher",
"ポケモンセンター": "Pokémon Center",
"ポケモンナース": "Pokémon Nurse",
"ポケモンパーク": "Pokémon Park",
"ポケモンパーソナリティテスト": "Pokémon Personality Test",
"ポケモンファンクラブ": "Pokémon Fan Club",
"ポケモンブリーダーフィールド": "Pokémon Breeder Fields",
"ポケモンマーチ": "Pokémon March",
"ポケモン交換おじさん": "Pokémon Trader",
"ポケモン再送信": "Pokémon Retransmit",
"ポケモン反転": "Pokémon Reversal",
"ポケモン回収": "Pokémon Retrieval",
"ポケモン図鑑": "Pokédex",
"ポケモン育て屋さん": "Pokémon Breeder",
"ポケモン通信": "Pokémon Communication",
"ポーション": "Potion",
"マサキ": "Bill",
"マスターボール": "Master Ball",
"マチス": "Lt. Surge",
"マチスの交渉": "Lt. Surge's Treaty",
"マチスの秘策": "Lt. Surge's Secret Plan",
"マルチテクニカルマシン01": "Multi Technical Machine 01",
"ミニスカート": "Lass",
"ミラクルベリー": "Miracle Berry",
"メアリー": "Mary",
"メアリーの衝動": "Mary's Impulse",
"メモリベリー": "Memory Berry",
"メンテナンス": "Maintenance",
"モンスターボール": "Poké Ball",
"ヤマブキシティジム": "Saffron City Gym",
"ライトニングキューブ01": "Lightning Cube 01",
"ラジオタワー": "Radio Tower",
"ラッキースタジアム": "Lucky Stadium",
"ランスのテクニカルマシン01": "Lance's Technical Machine 01",
"ランスのテクニカルマシン02": "Lance's Technical Machine 02",
"リコール": "Recall",
"リサイクル": "Recycle",
"リムーブ禁止ジム": "No Removal Gym",
"リーリエの決心": "Lillie's Determination",
"レインボーエネルギー": "Rainbow Energy",
"ロケットのスニーク攻撃": "Rocket's Sneak Attack",
"ロケットのテクニカルマシン01": "Rocket's Technical Machine 01",
"ロケットの隠れ家": "Rocket Hideout",
"ロケット団のおねーさん": "Rocket's Admin.",
"ロケット団のワナ": "Team Rocket's Trap",
"ロケット団の実験": "Team Rocket's Experiment",
"ロケット団の爆発ジム": "Explosion Gym",
"ロケット団の特訓ジム": "Training Center",
"ロケット団参上!": "Here Comes Team Rocket!",
"ロケット団員": "Team Rocket Grunt",
"ワープエネルギー": "Warp Energy",
"ワープポイント": "Warp Point",
"二重突風": "Double Gust",
"先見者": "Oracle",
"化石卵": "Fossil Egg",
"博士の研究": "Professor's Research",
"反撃の爪": "Counterattack Claws",
"古い棒": "Old Rod",
"基本ドラゴンエネルギー": "Dragon Energy",
"基本フェアリーエネルギー": "Fairy Energy",
"基本悪エネルギー": "Darkness Energy",
"基本水エネルギー": "Water Energy",
"基本炎エネルギー": "Fire Energy",
"基本草エネルギー": "Grass Energy",
"基本超エネルギー": "Psychic Energy",
"基本鋼エネルギー": "Metal Energy",
"基本闘エネルギー": "Fighting Energy",
"基本雷エネルギー": "Lightning Energy",
"壁を台無しにする[aerodactyl]": "Ruin Wall",
"壁を台無しにする[カブト]": "Ruin Wall",
"壊れた地上ジム": "Broken Ground Gym",
"夜のタンカ": "Night Stretcher",
"夜の廃品回収": "Nightly Garbage Run",
"大地の器": "Earthen Vessel",
"奇跡のエネルギー": "Miracle Energy",
"強さの魅力": "Power Charge",
"思い出させる": "Reminder",
"思考ウェーブマシン": "Thought Wave Machine",
"戦いキューブ01": "Fighting Cube 01",
"抵抗力低下ジム": "Resistance Gym",
"拡大鏡": "Magnifier",
"新しいpokedex": "New Pokédex",
"旅行セールスマン": "Traveling Salesman",
"森林保護者": "Forest Guardian",
"模倣": "Copycat",
"海底遺跡": "Undersea Ruins",
"町のボランティア": "Town Volunteers",
"癒しベリー": "Heal Berry",
"発電所": "Power Plant",
"礼儀作法": "Etiquette",
"突風": "Gust of Wind",
"粉末を癒します": "Heal Powder",
"脱力感ガード": "Weakness Guard",
"見えない壁": "Invisible Wall",
"詐欺師オーク教授": "Impostor Professor Oak",
"詐欺師オーク教授の発明": "Impostor Professor Oak's Invention",
"超エネルギーリムーブ": "Super Energy Removal",
"退化スプレー": "Devolution Spray",
"金属エネルギー": "Metal Energy",
"金属キューブ01": "Metal Cube 01",
"錯乱ジム": "Chaos Gym",
"闇のエネルギー": "Darkness Energy",
"闇キューブ01": "Darkness Cube 01"
}
+175
View File
@@ -0,0 +1,175 @@
{
"ADV1": "Expansion Pack ADV",
"ADV2": "Miracle of the Desert",
"ADV3": "Rulers of the Heavens",
"ADV4": "Flight of the Skies",
"ADV5": "Undone Seal",
"CP1": "Magma Gang VS Aqua Gang: Double Crisis",
"CP2": "Legendary Shine Collection",
"CP3": "PokéKyun Collection",
"CP4": "Premium Champion Pack",
"CP5": "Mythical & Legendary Dream Shine Collection",
"CP6": "Expansion Pack 20th Anniversary",
"E1": "Base Expansion Pack",
"E2": "The Town on No Map",
"E3": "Wind from the Sea",
"E4": "Split Earth",
"E5": "Mysterious Mountains",
"ExpSheet1": "Expansion Sheet Series 1",
"ExpSheet2": "Expansion Sheet Series 2",
"ExpSheet3": "Expansion Sheet Series 3",
"GurenTG": "Guren Town Gym",
"HanadaCG": "Hanada City Gym",
"KuchibaCG": "Kuchiba City Gym",
"L1a": "HeartGold Collection",
"L1b": "SoulSilver Collection",
"L2": "Reviving Legends",
"L3": "Clash at the Summit",
"LL": "Lost Link",
"M-P": "MEGA Promo",
"M1L": "Mega Symphonia",
"M1S": "Mega Symphonia",
"M2": "Inferno X",
"M2a": "MEGA Dream ex",
"M3": "Munikeith Zero",
"M4": "Ninja Spinner",
"M5": "MEGA Expansion",
"MC": "McDonald's Collection",
"NiviCG": "Nivi City Gym",
"PCG1": "Venusaur/Charizard/Blastoise Half Deck",
"PCG10": "Offense and Defense of the Furthest Ends",
"PCG2": "Flight of Fire",
"PCG3": "Clash of the Blue Sky",
"PCG4": "Rocket Gang Strikes Back",
"PCG5": "Golden Sky, Silvery Ocean",
"PCG6": "Mirage Forest",
"PCG7": "Holon Research Tower",
"PCG8": "Holon Phantom",
"PCG9": "Miracle Crystal",
"PMCG1": "Expansion Pack",
"PMCG2": "Pokémon Jungle",
"PMCG3": "Mystery of the Fossils",
"PMCG4": "Rocket Gang",
"PMCG5": "Leaders' Stadium",
"PMCG6": "Challenge from the Darkness",
"S10D": "Time Gazer",
"S10P": "Space Juggler",
"S10a": "Dark Abyss",
"S10b": "Pokémon GO",
"S11": "Lost Abyss",
"S11a": "Incandescent Arcana",
"S12": "Paradigm Trigger",
"S12a": "VSTAR Universe",
"S1H": "Shield",
"S1W": "Sword",
"S1a": "VMAX Rising",
"S2": "Rebellion Crash",
"S2a": "Explosive Walker",
"S3": "Infinity Zone",
"S3a": "Legendary Pulse",
"S4": "Amazing Volt Tackle",
"S4a": "Shiny Star V",
"S5I": "Single Strike Master",
"S5R": "Rapid Strike Master",
"S5a": "Matchless Fighters",
"S6H": "Silver Lance",
"S6K": "Jet-Black Spirit",
"S6a": "Eevee Heroes",
"S7D": "Skyscraping Perfection",
"S7R": "Blue Sky Stream",
"S8": "Fusion Arts",
"S8a": "Dark Phantasma",
"S8b": "VMAX Climax",
"S9": "Star Birth",
"S9a": "Battle Region",
"SM0": "Generation",
"SM1+": "Strength Expansion Pack Sun & Moon",
"SM10": "Double Blaze",
"SM10b": "Sky Legend",
"SM11a": "Remix Bout",
"SM11b": "Dream League",
"SM12": "Alter Genesis",
"SM12a": "Tag All Stars",
"SM1M": "Collection Moon",
"SM1S": "Collection Sun",
"SM2K": "Islands Await You",
"SM2L": "Alolan Moonlight",
"SM3+": "Shining Legends",
"SM3H": "Fighting Rainbow",
"SM3N": "Darkness that Consumes Light",
"SM4+": "GX Battle Boost",
"SM4A": "Ultradimensional Beasts",
"SM4S": "Awakened Heroes",
"SM5+": "Ultra Force Ultra Sun Ultra Moon",
"SM5M": "Ultra Force",
"SM5S": "Ultra Moon",
"SM6": "Forbidden Light",
"SM6a": "Dragon Storm",
"SM6b": "Champion Road",
"SM7": "Fairy Rise",
"SM7a": "Thunderclap Spark",
"SM7b": "Fairy Rise",
"SM8": "Explosive Impact",
"SM8a": "Dark Order",
"SM8b": "GX Ultra Shiny",
"SM9": "Tag Bolt",
"SM9a": "Night Unison",
"SM9b": "Full Metal Wall",
"SMP2": "Detective Pikachu",
"SV10": "Glory of Team Rocket",
"SV11B": "Black Bolt",
"SV11W": "White Flare",
"SV1S": "Scarlet ex",
"SV1V": "Violet ex",
"SV1a": "Triplet Beat",
"SV2D": "Clay Burst",
"SV2P": "Snow Hazard",
"SV2a": "Pokémon Card 151",
"SV3": "Ruler of the Black Flame",
"SV3a": "Raging Surf",
"SV4K": "Ancient Roar",
"SV4M": "Future Flash",
"SV4a": "Shiny Treasure ex",
"SV5K": "Wild Force",
"SV5M": "Cyber Judge",
"SV5a": "Crimson Haze",
"SV6": "Mask of Change",
"SV6a": "Night Wanderer",
"SV7": "Stellar Miracle",
"SV7a": "Paradise Dragona",
"SV8": "Super Electric Breaker",
"SV8a": "Terastal Festival ex",
"SV9": "Battle Partners",
"SV9a": "Heat Wave Arena",
"SVK": "Starter Set / Construction Dec",
"SVLN": "Starter Set Terastal Charizard ex",
"SVLS": "Starter Set Lucario & Roaring Moon",
"SouthernIslands": "Southern Islands",
"TamamushiCG": "Tamamushi City Gym",
"UnnumberedPromo": "Unnumbered Promotional cards",
"VS1": "Pokémon VS",
"XY10": "Awakening Psychic King",
"XY11a": "Cruel Traitor",
"XY11b": "Fever-Burst Fight",
"XY1a": "Collection X",
"XY1b": "Collection Y",
"XY2": "Wild Blaze",
"XY3": "Rising Fist",
"XY4": "Phantom Gate",
"XY5a": "Gaia Volcano",
"XY5b": "Tidal Storm",
"XY6": "Emerald Break",
"XY7": "Bandit Ring",
"XY8a": "Blue Shock",
"XY8b": "Red Flash",
"XY9": "Rage of the Broken Heavens",
"YamabukiCG": "Yamabuki City Gym",
"neo1": "Gold, Silver, to a New World...",
"neo2": "Crossing the Ruins...",
"neo3": "Awakening Legends",
"neo4": "Darkness, and to Light...",
"sm2+": "Facing a New Trial",
"sn10a": "GG End",
"sn11": "Miracle Twin",
"web1": "Pokémon Web"
}
File diff suppressed because it is too large Load Diff