Files
Card-Collection-Manager-3/tools/pokemon_jp/enrich_neo_image_urls.py
T
2026-07-22 11:13:42 +02:00

290 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Fill neo1neo4 catalog image_url from Japanese CardIndex scans only.
TCGdex JA neo sets have image:null. This ETL scrapes CardIndex Japanese set
pages (Awakening Legends, etc.) and writes HTTPS image_url values for exact
JA setId+localId catalog rows.
Policy (UnnumberedPromo parity): Japanese scans only. If CardIndex has no JP
image for a print, image_url is cleared — never store English pokemontcg.io
art as a fallback.
Usage:
python tools/pokemon_jp/enrich_neo_image_urls.py
python tools/pokemon_jp/enrich_neo_image_urls.py --dry-run
python tools/pokemon_jp/enrich_neo_image_urls.py --overwrite
python tools/pokemon_jp/enrich_neo_image_urls.py --overwrite --limit 20
"""
from __future__ import annotations
import argparse
import json
import re
import time
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
OUT = ROOT / "ui_wx" / "assets" / "pokemon_jp_en_catalog.json"
NEO_SETS = frozenset({"neo1", "neo2", "neo3", "neo4"})
UA = {
"User-Agent": (
"CCM3-pokemon-jp-etl/1.0 "
"(local; +https://github.com/sebastiandine/Card-Collection-Manager-3)"
)
}
# JA neo set id -> (CardIndex set page slug, image CDN folder)
SET_META: dict[str, tuple[str, str]] = {
"neo1": ("gold-silver-to-a-new-world", "neo-jp-gold-silver"),
"neo2": ("crossing-the-ruins", "neo-jp-crossing-ruins"),
"neo3": ("awakening-legends", "neo-jp-awakening-legends"),
"neo4": ("darkness-and-to-light", "neo-jp-darkness-light"),
}
IMG_RE = re.compile(
r"https://images\.cardindex\.co/cardindex-images/cards/"
r"(?P<folder>[^/\"']+)/(?P<file>[^\"'\s>]+)\.(?P<ext>jpe?g|png|webp)",
re.IGNORECASE,
)
def log(msg: str) -> None:
try:
print(msg, flush=True)
except UnicodeEncodeError:
print(msg.encode("ascii", errors="replace").decode("ascii"), flush=True)
def http_get(url: str, timeout: float = 60.0) -> str:
req = urllib.request.Request(url, headers=UA)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read().decode("utf-8", "replace")
def normalize_name(s: str) -> str:
"""Lowercase alnum tokens for matching catalog names to CardIndex slugs."""
s = s.lower().replace("\u2019", "'").replace("'", "")
s = re.sub(r"[^a-z0-9]+", " ", s)
return " ".join(s.split())
def slug_base_name(slug: str) -> str:
"""shining-magikarp-129 / balloon-berry-promo -> shining magikarp / balloon berry."""
s = slug.strip().lower()
s = re.sub(r"-promo$", "", s)
s = re.sub(r"-\d+$", "", s)
return normalize_name(s.replace("-", " "))
def pick_set_image(html: str, image_folder: str, card_slug: str) -> str | None:
"""Prefer full-size JP scan in this set's CDN folder for this card slug."""
folder_l = image_folder.lower()
slug_l = card_slug.lower()
full: list[str] = []
small: list[str] = []
for m in IMG_RE.finditer(html):
if m.group("folder").lower() != folder_l:
continue
file_stem = m.group("file").lower()
# Require the card's own slug (with optional -small).
if not (file_stem == slug_l or file_stem == f"{slug_l}-small"):
# Also accept promo variant files named "{base}-promo".
base = re.sub(r"-\d+$", "", slug_l)
if not (
file_stem == f"{base}-promo"
or file_stem == f"{base}-promo-small"
or file_stem.startswith(f"{slug_l}")
):
continue
url = m.group(0)
if file_stem.endswith("-small"):
small.append(url)
else:
full.append(url)
if full:
# Prefer exact slug match over promo/other.
for u in full:
if f"/{slug_l}." in u.lower():
return u
for u in full:
if f"/{slug_l}-" not in u.lower() or "-promo." in u.lower():
return u
return full[0]
if small:
# Upgrade -small to full-size URL when possible.
u = small[0]
return re.sub(r"-small\.(jpe?g|png|webp)$", r".\1", u, flags=re.I)
return None
def scrape_set_index(
set_id: str, *, sleep_s: float
) -> dict[str, list[tuple[str, str]]]:
"""Return normalize_name -> [(card_slug, image_url), ...] for one neo set."""
page_slug, image_folder = SET_META[set_id]
set_url = f"https://www.cardindex.co/pokemon-cards/{page_slug}"
log(f"scraping set index {set_id}: {set_url}")
html = http_get(set_url)
card_slugs = sorted(
set(
re.findall(
rf"/pokemon-cards/{re.escape(page_slug)}/([a-z0-9\-]+)",
html,
)
)
)
log(f" {len(card_slugs)} card pages")
by_name: dict[str, list[tuple[str, str]]] = {}
for i, slug in enumerate(card_slugs, start=1):
card_url = f"https://www.cardindex.co/pokemon-cards/{page_slug}/{slug}"
try:
time.sleep(sleep_s)
card_html = http_get(card_url)
except (urllib.error.URLError, TimeoutError) as exc:
log(f" [{i}/{len(card_slugs)}] FAIL {slug}: {exc}")
continue
img = pick_set_image(card_html, image_folder, slug)
if not img:
log(f" [{i}/{len(card_slugs)}] no JP image {slug}")
continue
name = slug_base_name(slug)
by_name.setdefault(name, []).append((slug, img))
log(f" [{i}/{len(card_slugs)}] {name!r} <- {img}")
return by_name
def resolve_url_for_print(
name_en: str, index: dict[str, list[tuple[str, str]]]
) -> str | None:
key = normalize_name(name_en)
if not key:
return None
hits = index.get(key) or []
if not hits:
return None
# Unique image only — ambiguous Unown / multi-print names stay empty.
urls = sorted({u for _slug, u in hits})
if len(urls) == 1:
return urls[0]
return None
def enrich_neo_images(
catalog: dict,
*,
dry_run: bool,
overwrite: bool,
sleep_s: float,
limit: int,
out_path: Path,
) -> tuple[int, int, int, int]:
prints = catalog.get("prints", [])
candidates = [
p
for p in prints
if p.get("set_id") in NEO_SETS and (p.get("name_en") or "").strip()
]
if not overwrite:
candidates = [
p for p in candidates if not (p.get("image_url") or "").strip()
]
if limit > 0:
candidates = candidates[:limit]
# Scrape only the sets we need.
needed_sets = sorted({str(p["set_id"]) for p in candidates})
indexes: dict[str, dict[str, list[tuple[str, str]]]] = {}
for sid in needed_sets:
indexes[sid] = scrape_set_index(sid, sleep_s=sleep_s)
filled = 0
changed = 0
missed = 0
updates = 0
def persist() -> None:
if dry_run:
return
out_path.write_text(
json.dumps(catalog, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
for i, p in enumerate(candidates, start=1):
sid = str(p["set_id"])
lid = str(p["local_id"])
name_en = str(p["name_en"]).strip()
prev = (p.get("image_url") or "").strip()
log(f"[{i}/{len(candidates)}] {sid}-{lid} {name_en!r} …")
url = resolve_url_for_print(name_en, indexes.get(sid, {}))
if url:
if not dry_run:
p["image_url"] = url
filled += 1
if url != prev:
changed += 1
updates += 1
log(f" -> {url}" + (f" (was {prev})" if prev else ""))
else:
log(f" -> {url} (unchanged)")
else:
missed += 1
if prev:
if not dry_run:
p.pop("image_url", None)
changed += 1
updates += 1
log(f" -> (miss, cleared {prev})")
else:
log(" -> (miss)")
if updates >= 25:
persist()
updates = 0
log(f" checkpoint wrote {out_path}")
persist()
return len(candidates), filled, changed, missed
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--out", type=Path, default=OUT)
ap.add_argument("--dry-run", action="store_true")
ap.add_argument(
"--overwrite",
action="store_true",
help="Re-resolve neo prints that already have image_url (clears EN URLs)",
)
ap.add_argument("--limit", type=int, default=0, help="Max neo prints to process")
ap.add_argument("--sleep", type=float, default=0.35, help="Seconds between HTTP calls")
args = ap.parse_args()
if not args.out.is_file():
raise SystemExit(f"catalog not found: {args.out}")
catalog = json.loads(args.out.read_text(encoding="utf-8"))
total, filled, changed, missed = enrich_neo_images(
catalog,
dry_run=args.dry_run,
overwrite=args.overwrite,
sleep_s=args.sleep,
limit=args.limit,
out_path=args.out,
)
print(
f"neo image_url: candidates={total} filled={filled} changed={changed} "
f"missed={missed}"
+ (" (dry-run)" if args.dry_run else f" wrote {args.out}"),
flush=True,
)
if __name__ == "__main__":
main()