188 lines
6.0 KiB
Python
188 lines
6.0 KiB
Python
"""
|
|
Scryfall API client with Redis caching.
|
|
|
|
Respects Scryfall's rate limit (10 req/s with a small safety margin).
|
|
Cards are cached for 24 hours by default (SCRYFALL_CACHE_TTL_SECONDS).
|
|
"""
|
|
import asyncio
|
|
import json
|
|
from typing import Optional
|
|
|
|
import httpx
|
|
from tenacity import retry, stop_after_attempt, wait_exponential
|
|
|
|
from app.core.config import settings
|
|
from app.core.redis import get_redis
|
|
|
|
SCRYFALL_BASE = "https://api.scryfall.com"
|
|
_semaphore: asyncio.Semaphore | None = None
|
|
|
|
|
|
def _get_semaphore() -> asyncio.Semaphore:
|
|
global _semaphore
|
|
if _semaphore is None:
|
|
_semaphore = asyncio.Semaphore(settings.SCRYFALL_RATE_LIMIT_RPS)
|
|
return _semaphore
|
|
|
|
|
|
def _cache_key(kind: str, value: str) -> str:
|
|
return f"scryfall:{kind}:{value.lower()}"
|
|
|
|
|
|
async def _cache_get(key: str) -> Optional[dict]:
|
|
redis = await get_redis()
|
|
raw = await redis.get(key)
|
|
return json.loads(raw) if raw else None
|
|
|
|
|
|
async def _cache_set(key: str, data: dict) -> None:
|
|
redis = await get_redis()
|
|
await redis.set(key, json.dumps(data), ex=settings.SCRYFALL_CACHE_TTL_SECONDS)
|
|
|
|
|
|
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=0.5, min=0.5, max=4))
|
|
async def _get(path: str, params: dict | None = None) -> dict:
|
|
async with _get_semaphore():
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
resp = await client.get(
|
|
f"{SCRYFALL_BASE}{path}",
|
|
params=params,
|
|
headers={"User-Agent": "MTGDeckBuilder/1.0"},
|
|
)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
|
|
async def get_card_by_name(name: str, exact: bool = True) -> Optional[dict]:
|
|
"""Fetch a card by name. Uses fuzzy search if exact=False."""
|
|
key = _cache_key("name", name)
|
|
cached = await _cache_get(key)
|
|
if cached:
|
|
return cached
|
|
|
|
try:
|
|
if exact:
|
|
data = await _get("/cards/named", {"exact": name})
|
|
else:
|
|
data = await _get("/cards/named", {"fuzzy": name})
|
|
await _cache_set(key, data)
|
|
# Also cache by scryfall id
|
|
await _cache_set(_cache_key("id", data["id"]), data)
|
|
return data
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
return None
|
|
raise
|
|
|
|
|
|
async def get_card_by_id(scryfall_id: str) -> Optional[dict]:
|
|
"""Fetch a card by its Scryfall UUID."""
|
|
key = _cache_key("id", scryfall_id)
|
|
cached = await _cache_get(key)
|
|
if cached:
|
|
return cached
|
|
|
|
try:
|
|
data = await _get(f"/cards/{scryfall_id}")
|
|
await _cache_set(key, data)
|
|
return data
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
return None
|
|
raise
|
|
|
|
|
|
async def get_card_by_set_and_collector(set_code: str, collector_number: str) -> Optional[dict]:
|
|
"""Fetch a card by set + collector number (used during import enrichment)."""
|
|
key = _cache_key("setcol", f"{set_code}:{collector_number}")
|
|
cached = await _cache_get(key)
|
|
if cached:
|
|
return cached
|
|
|
|
try:
|
|
data = await _get(f"/cards/{set_code.lower()}/{collector_number}")
|
|
await _cache_set(key, data)
|
|
await _cache_set(_cache_key("id", data["id"]), data)
|
|
return data
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
return None
|
|
raise
|
|
|
|
|
|
async def batch_enrich_by_name(names: list[str]) -> dict[str, Optional[dict]]:
|
|
"""
|
|
Resolve up to 75 card names at once via Scryfall's /cards/collection endpoint.
|
|
Returns a dict of {name_lower: scryfall_data}.
|
|
Falls back to None for cards not found.
|
|
"""
|
|
results: dict[str, Optional[dict]] = {}
|
|
uncached: list[str] = []
|
|
|
|
# Check cache first
|
|
for name in names:
|
|
key = _cache_key("name", name)
|
|
cached = await _cache_get(key)
|
|
if cached:
|
|
results[name.lower()] = cached
|
|
else:
|
|
uncached.append(name)
|
|
|
|
# Batch fetch uncached in chunks of 75 (Scryfall limit)
|
|
for chunk_start in range(0, len(uncached), 75):
|
|
chunk = uncached[chunk_start : chunk_start + 75]
|
|
identifiers = [{"name": n} for n in chunk]
|
|
|
|
async with _get_semaphore():
|
|
async with httpx.AsyncClient(timeout=15) as client:
|
|
resp = await client.post(
|
|
f"{SCRYFALL_BASE}/cards/collection",
|
|
json={"identifiers": identifiers},
|
|
headers={"User-Agent": "MTGDeckBuilder/1.0"},
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
|
|
for card in data.get("data", []):
|
|
name_lower = card["name"].lower()
|
|
results[name_lower] = card
|
|
await _cache_set(_cache_key("name", card["name"]), card)
|
|
await _cache_set(_cache_key("id", card["id"]), card)
|
|
|
|
# Mark not-found as None
|
|
found_lower = {c["name"].lower() for c in data.get("data", [])}
|
|
for name in chunk:
|
|
if name.lower() not in found_lower:
|
|
results[name.lower()] = None
|
|
|
|
# Be polite between chunks
|
|
if chunk_start + 75 < len(uncached):
|
|
await asyncio.sleep(0.15)
|
|
|
|
return results
|
|
|
|
|
|
def extract_price_usd(scryfall_data: dict) -> Optional[float]:
|
|
"""Pull the cheapest non-foil USD price from a Scryfall card object."""
|
|
try:
|
|
price = scryfall_data.get("prices", {}).get("usd")
|
|
return float(price) if price else None
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def card_image_url(scryfall_data: dict, size: str = "normal") -> Optional[str]:
|
|
"""Return an image URL. Handles split/transform/MDFCs gracefully."""
|
|
images = scryfall_data.get("image_uris")
|
|
if images:
|
|
return images.get(size)
|
|
# Double-faced cards store images per face
|
|
faces = scryfall_data.get("card_faces", [])
|
|
if faces:
|
|
return faces[0].get("image_uris", {}).get(size)
|
|
return None
|
|
|
|
|
|
def is_commander_legal(scryfall_data: dict) -> bool:
|
|
return scryfall_data.get("legalities", {}).get("commander") == "legal"
|