Judge / backend /scripts /parse_cards.py
GonzaViss's picture
fix(cards): collapse cosmetic card variants by shared artwork
26a4699
Raw
History Blame Contribute Delete
10.5 kB
"""
Parsea cartas de Riftbound desde la Riftcodex API (https://api.riftcodex.com/cards).
Modos:
- URL: pagina el API, cachea el payload crudo en data/raw/cards.json,
y emite data/processed/cards.md
- Path local: lee el JSON cacheado y emite el markdown
Cada carta se renderiza como un bloque H2 (## clean_name) con metadata
estructurada en el body. clean_name es el slug que usa el sistema de @tags
del judge, asΓ­ `@yasuo` matchea section="yasuo" en tagged_lookup.
"""
import json
import sys
import time
from pathlib import Path
import requests
_API_BASE = "https://api.riftcodex.com/cards"
_HEADERS = {"User-Agent": "RiftboundJudgeBot/1.0 (+https://github.com/GonzaloAsencio/Judge)"}
_PAGE_SIZE = 100
_REQUEST_PAUSE_S = 0.5
_MAX_RETRIES = 3
# ---------------------------------------------------------------------------
# Fetch + cache
# ---------------------------------------------------------------------------
def _extract_items(payload) -> list[dict]:
"""The Riftcodex API may wrap results in {items|data|results: [...]}.
Accept any of those keys, or a bare list. Returns [] if the shape is
unrecognised (caller decides whether to keep paginating).
"""
if isinstance(payload, list):
return payload
if isinstance(payload, dict):
for key in ("items", "data", "results", "cards"):
value = payload.get(key)
if isinstance(value, list):
return value
return []
def _fetch_pages(url: str = _API_BASE) -> list[dict]:
cards: list[dict] = []
page = 1
while True:
params = {"page": page, "size": _PAGE_SIZE}
for attempt in range(_MAX_RETRIES):
resp = requests.get(url, params=params, headers=_HEADERS, timeout=30)
if resp.status_code == 429:
wait = 2 ** attempt
print(f" Rate limited on page {page}, sleeping {wait}s")
time.sleep(wait)
continue
resp.raise_for_status()
break
else:
raise RuntimeError(f"Riftcodex API kept returning 429 on page {page}")
items = _extract_items(resp.json())
if not items:
break
cards.extend(items)
print(f" Page {page}: {len(items)} cards (total so far: {len(cards)})")
if len(items) < _PAGE_SIZE:
break
page += 1
time.sleep(_REQUEST_PAUSE_S)
return cards
# ---------------------------------------------------------------------------
# Filter β€” alternate art + dedupe
# ---------------------------------------------------------------------------
def _filter_cards(cards: list[dict]) -> list[dict]:
"""Drop alternate-art versions and dedupe by riftbound_id (first wins)."""
seen: set[str] = set()
result: list[dict] = []
for card in cards:
metadata = card.get("metadata") or {}
if metadata.get("alternate_art"):
continue
rb_id = card.get("riftbound_id")
if not rb_id or rb_id in seen:
continue
seen.add(rb_id)
result.append(card)
return result
# ---------------------------------------------------------------------------
# Render
# ---------------------------------------------------------------------------
def _render_card(card: dict) -> str:
"""Render a single card to markdown. Returns "" if essential fields missing."""
metadata = card.get("metadata") or {}
clean_name = metadata.get("clean_name")
name = card.get("name")
classification = card.get("classification") or {}
card_type = classification.get("type")
if not clean_name or not name or not card_type:
return ""
lines: list[str] = [f"## {clean_name}", f"**Name**: {name}"]
set_info = card.get("set") or {}
set_label = set_info.get("label")
riftbound_id = card.get("riftbound_id")
set_parts: list[str] = []
if set_label and riftbound_id:
set_parts.append(f"**Set**: {set_label} ({riftbound_id})")
elif set_label:
set_parts.append(f"**Set**: {set_label}")
rarity = classification.get("rarity")
if rarity:
set_parts.append(f"**Rarity**: {rarity}")
domain = classification.get("domain") or []
if domain:
set_parts.append(f"**Domain**: {', '.join(domain)}")
if set_parts:
lines.append(" | ".join(set_parts))
attributes = card.get("attributes") or {}
stat_parts: list[str] = []
for key, label in (("energy", "Energy"), ("might", "Might"), ("power", "Power")):
value = attributes.get(key)
if value is not None:
stat_parts.append(f"**{label}**: {value}")
stat_parts.append(f"**Type**: {card_type}")
supertype = classification.get("supertype")
if supertype:
stat_parts.append(f"**Supertype**: {supertype}")
lines.append(" | ".join(stat_parts))
tags = card.get("tags") or []
if tags:
lines.append(f"**Tags**: {', '.join(tags)}")
text = card.get("text") or {}
plain = text.get("plain")
if plain:
lines.append("")
lines.append("**Text**:")
lines.append(plain)
flavour = text.get("flavour")
if flavour:
lines.append("")
lines.append(f'*Flavor*: "{flavour}"')
return "\n".join(lines)
def _render_markdown(cards: list[dict]) -> str:
"""Filter + render every card. Output is one H2 block per card, separated by blank lines."""
blocks = []
for card in _filter_cards(cards):
block = _render_card(card)
if block:
blocks.append(block)
return "\n\n".join(blocks)
# ---------------------------------------------------------------------------
# Frontend lookup index β€” name β†’ image_url mapping for card preview popovers
# ---------------------------------------------------------------------------
_TS_BANNER = (
"// AUTO-GENERATED by backend/scripts/parse_cards.py β€” do not edit by hand.\n"
"// Regenerate with: python backend/scripts/parse_cards.py\n"
)
_TS_INTERFACE = """
export interface CardIndexEntry {
clean_name: string;
image_url: string;
set_label: string;
riftbound_id: string;
}
"""
def build_card_index(cards: list[dict]) -> list[dict]:
"""Build the frontend lookup index from raw Riftcodex card records.
Filters: alternate_art, signature, overnumbered (D6 in the plan).
Skips cards missing clean_name or media.image_url β€” can't preview them.
A card's identity is its artwork (image_url). Cosmetic variants β€” foil
("... Metal"), promo ("... GG EZ", "... Starter", "... Launch Exclusive") β€”
carry a different clean_name but reuse the base card's image. We collapse by
image_url and keep the shortest clean_name so the base card wins over its
variant. Distinct clean_names that survive under different images are then
deduped by name (first match wins).
"""
by_image: dict[str, dict] = {}
order: list[str] = []
for card in cards:
metadata = card.get("metadata") or {}
if metadata.get("alternate_art") or metadata.get("signature") or metadata.get("overnumbered"):
continue
clean_name = metadata.get("clean_name")
if not clean_name:
continue
key = clean_name.strip().lower()
if not key:
continue
media = card.get("media") or {}
image_url = media.get("image_url")
if not image_url:
continue
set_info = card.get("set") or {}
entry = {
"clean_name": key,
"image_url": image_url,
"set_label": set_info.get("label") or "",
"riftbound_id": card.get("riftbound_id") or "",
}
existing = by_image.get(image_url)
if existing is None:
by_image[image_url] = entry
order.append(image_url)
elif len(key) < len(existing["clean_name"]):
by_image[image_url] = entry
seen_names: set[str] = set()
index: list[dict] = []
for image_url in order:
entry = by_image[image_url]
if entry["clean_name"] in seen_names:
continue
seen_names.add(entry["clean_name"])
index.append(entry)
return index
def serialize_card_index_ts(entries: list[dict]) -> str:
"""Render the index as a TypeScript module with a typed `as const` array."""
body = json.dumps(entries, indent=2, ensure_ascii=False)
return (
f"{_TS_BANNER}"
f"{_TS_INTERFACE}\n"
f"export const CARD_INDEX: readonly CardIndexEntry[] = {body} as const;\n"
)
# ---------------------------------------------------------------------------
# Entry point β€” local JSON OR live API
# ---------------------------------------------------------------------------
def parse_cards(source: str | Path = _API_BASE) -> str:
"""Build cards.md content.
- If source is an existing file path β†’ read JSON from it.
- Otherwise β†’ treat as URL, paginate, return rendered markdown.
"""
path = Path(str(source))
if path.exists():
payload = json.loads(path.read_text(encoding="utf-8"))
cards = _extract_items(payload) if not isinstance(payload, list) else payload
else:
cards = _fetch_pages(str(source))
return _render_markdown(cards)
if __name__ == "__main__":
raw_out = Path("data/raw/cards.json")
md_out = Path("data/processed/cards.md")
ts_out = Path("../frontend/lib/cardIndex.ts")
raw_out.parent.mkdir(parents=True, exist_ok=True)
md_out.parent.mkdir(parents=True, exist_ok=True)
ts_out.parent.mkdir(parents=True, exist_ok=True)
source = sys.argv[1] if len(sys.argv) > 1 else _API_BASE
print(f"Fetching cards from: {source}")
if Path(source).exists():
cards_payload = json.loads(Path(source).read_text(encoding="utf-8"))
cards = _extract_items(cards_payload) if not isinstance(cards_payload, list) else cards_payload
else:
cards = _fetch_pages(source)
raw_out.write_text(json.dumps(cards, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"Cached raw payload: {raw_out} ({len(cards)} cards)")
md = _render_markdown(cards)
md_out.write_text(md, encoding="utf-8")
chunk_count = md.count("## ")
print(f"Generated: {md_out} ({len(md):,} chars, {chunk_count} cards)")
index = build_card_index(cards)
ts_out.write_text(serialize_card_index_ts(index), encoding="utf-8")
print(f"Generated: {ts_out} ({len(index)} cards in lookup index)")