Restore hydrate.py to repo
This commit is contained in:
227
hydrate.py
Normal file
227
hydrate.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user