diff --git a/hydrate.py b/hydrate.py new file mode 100644 index 0000000..c0e143d --- /dev/null +++ b/hydrate.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +""" +Scryfall decklist hydrator for EDH decks. +Fetches card data from Scryfall API and organizes decklists by card type. +""" + +import argparse +import json +import os +import re +import sys +import time +from pathlib import Path +from typing import Optional +import urllib.request +import urllib.error +import urllib.parse + + +SCRYFALL_API = "https://api.scryfall.com" +RATE_LIMIT_DELAY = 0.1 # 100ms between requests + +FIELDS = [ + "name", "mana_cost", "cmc", "colors", "color_identity", + "type_line", "oracle_text", "power", "toughness", "loyalty" +] + + +def parse_decklist(filepath: str) -> list[dict]: + """Parse a decklist file into list of {count, name} dicts.""" + cards = [] + with open(filepath, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + match = re.match(r"^(\d+)x?\s+(.+)$", line, re.IGNORECASE) + if match: + cards.append({"count": int(match.group(1)), "name": match.group(2).strip()}) + return cards + + +def fetch_card(name: str) -> Optional[dict]: + """Fetch card data from Scryfall API using fuzzy search.""" + encoded = urllib.parse.quote(name) + url = f"{SCRYFALL_API}/cards/named?fuzzy={encoded}" + + try: + req = urllib.request.Request(url, headers={ + "User-Agent": "EDHDeckBuilder/1.0", + "Accept": "*/*" + }) + with urllib.request.urlopen(req, timeout=30) as response: + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as e: + print(f" Error fetching '{name}': HTTP {e.code}", file=sys.stderr) + return None + except urllib.error.URLError as e: + print(f" Error fetching '{name}': {e.reason}", file=sys.stderr) + return None + except json.JSONDecodeError: + print(f" Error parsing response for '{name}'", file=sys.stderr) + return None + + +def extract_card_info(card_data: dict) -> dict: + """Extract relevant fields from Scryfall card data.""" + result = {"scryfall_uri": card_data.get("scryfall_uri", "")} + + for field in FIELDS: + result[field] = card_data.get(field) + + if card_data.get("card_faces"): + face = card_data["card_faces"][0] + for field in ["mana_cost", "type_line", "oracle_text", "power", "toughness"]: + if result.get(field) is None: + result[field] = face.get(field) + + result["colors"] = card_data.get("colors", []) + result["color_identity"] = card_data.get("color_identity", []) + + return result + + +def categorize_by_type(cards: list[dict]) -> dict[str, list[dict]]: + """Categorize cards by their primary type line.""" + categories = { + "commander": [], + "creatures": [], + "instants": [], + "sorceries": [], + "artifacts": [], + "enchantments": [], + "planeswalkers": [], + "lands": [], + "other": [], + } + + for card in cards: + type_line = card.get("type_line", "").lower() + + if "legendary" in type_line and ("creature" in type_line or "planeswalker" in type_line): + if not categories["commander"]: + categories["commander"].append(card) + continue + + if "creature" in type_line: + categories["creatures"].append(card) + elif "instant" in type_line: + categories["instants"].append(card) + elif "sorcery" in type_line: + categories["sorceries"].append(card) + elif "artifact" in type_line: + categories["artifacts"].append(card) + elif "enchantment" in type_line: + categories["enchantments"].append(card) + elif "planeswalker" in type_line: + categories["planeswalkers"].append(card) + elif "land" in type_line: + categories["lands"].append(card) + else: + categories["other"].append(card) + + return {k: v for k, v in categories.items() if v} + + +def hydrate_decklist(input_file: str, output_dir: str, cache_file: Optional[str] = None) -> None: + """Main hydration function.""" + cache = {} + if cache_file and os.path.exists(cache_file): + with open(cache_file, "r", encoding="utf-8") as f: + cache = json.load(f) + print(f"Loaded {len(cache)} cached cards") + + print(f"Parsing decklist: {input_file}") + entries = parse_decklist(input_file) + print(f"Found {len(entries)} unique card entries") + + hydrated = [] + for i, entry in enumerate(entries, 1): + name = entry["name"] + count = entry["count"] + + if name in cache: + card_info = cache[name] + print(f"[{i}/{len(entries)}] {name} (cached)") + else: + print(f"[{i}/{len(entries)}] Fetching: {name}...") + card_data = fetch_card(name) + if card_data: + card_info = extract_card_info(card_data) + cache[name] = card_info + time.sleep(RATE_LIMIT_DELAY) + else: + card_info = {"name": name, "error": "not found"} + + card_info["count"] = count + hydrated.append(card_info) + + if cache_file: + with open(cache_file, "w", encoding="utf-8") as f: + json.dump(cache, f, indent=2) + print(f"Cached {len(cache)} cards to {cache_file}") + + categories = categorize_by_type(hydrated) + + os.makedirs(output_dir, exist_ok=True) + + all_cards_path = os.path.join(output_dir, "deck.json") + with open(all_cards_path, "w", encoding="utf-8") as f: + json.dump(hydrated, f, indent=2) + print(f"Wrote full decklist to {all_cards_path}") + + for category, cards in categories.items(): + cat_path = os.path.join(output_dir, f"{category}.json") + with open(cat_path, "w", encoding="utf-8") as f: + json.dump(cards, f, indent=2) + print(f" {category}: {len(cards)} cards -> {cat_path}") + + print(f"\nDeck summary:") + print(f" Total cards: {sum(c.get('count', 1) for c in hydrated)}") + print(f" Unique cards: {len(hydrated)}") + + +def create_deck(deck_name: str, base_dir: str = "decks") -> str: + """Create a new deck folder structure.""" + deck_path = os.path.join(base_dir, deck_name) + os.makedirs(deck_path, exist_ok=True) + + template = { + "name": deck_name, + "commander": None, + "cards": [] + } + + with open(os.path.join(deck_path, "deck.json"), "w", encoding="utf-8") as f: + json.dump(template, f, indent=2) + + print(f"Created deck: {deck_path}") + return deck_path + + +def main(): + parser = argparse.ArgumentParser(description="Hydrate MTG decklists with Scryfall data") + subparsers = parser.add_subparsers(dest="command", help="Commands") + + hydrate_parser = subparsers.add_parser("hydrate", help="Hydrate a decklist with Scryfall data") + hydrate_parser.add_argument("input", help="Input decklist file") + hydrate_parser.add_argument("-o", "--output", default="output", help="Output directory") + hydrate_parser.add_argument("-c", "--cache", default="card_cache.json", help="Cache file for card data") + + new_parser = subparsers.add_parser("new", help="Create a new deck folder") + new_parser.add_argument("name", help="Deck name") + new_parser.add_argument("-d", "--dir", default="decks", help="Base directory for decks") + + args = parser.parse_args() + + if args.command == "hydrate": + hydrate_decklist(args.input, args.output, args.cache) + elif args.command == "new": + create_deck(args.name, args.dir) + else: + parser.print_help() + + +if __name__ == "__main__": + main()