Files
mtg-builder/hydrate.py
Tuan-Dat Tran eea3a6a659 Refactor project structure and update documentation
- Migrated data files to 'data/collection/' and 'data/decks/'.
- Moved 'card_cache.json' to 'cache/'.
- Reorganized 'collection_hydrated/' and 'deck_analysis.json' into 'output/'.
- Updated 'hydrate.py' and script defaults to match the new paths.
- Updated 'README.template.md' and 'AGENTS.template.md' templates.
- Regenerated 'README.md' and 'AGENTS.md'.
2026-02-26 14:51:48 +01:00

228 lines
7.6 KiB
Python

#!/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/hydrated", help="Output directory")
hydrate_parser.add_argument("-c", "--cache", default="cache/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="data/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()