- 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'.
228 lines
7.6 KiB
Python
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()
|