| """ |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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) |
|
|
|
|
| |
| |
| |
|
|
| _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" |
| ) |
|
|
|
|
| |
| |
| |
|
|
| 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)") |
|
|