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