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

568 lines
18 KiB
Python

#!/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()