mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-28 17:01:02 +00:00
290 lines
9.1 KiB
Python
290 lines
9.1 KiB
Python
#!/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()
|