mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-29 01:08:49 +00:00
Minor: Add Asian Pokemon Card Support (#18)
This commit is contained in:
@@ -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 neo1–neo4
|
||||
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 neo1–neo4 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 neo1–neo4 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
|
||||
```
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
#REDIRECT [[Fossil (TCG)]]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fill neo1–neo4 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()
|
||||
@@ -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()
|
||||
@@ -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 δ / & 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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("'", "'")
|
||||
.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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user