M1: fetch-static — cdragon download, parse, icon URLs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
24
backend/tft/cli.py
Normal file
24
backend/tft/cli.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import argparse
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(prog="tft")
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
p_fetch = sub.add_parser("fetch-static", help="download static data from Community Dragon")
|
||||||
|
p_fetch.add_argument("--set", type=int, default=None, help="override set number")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.command == "fetch-static":
|
||||||
|
from tft.staticdata.fetch import fetch_static
|
||||||
|
|
||||||
|
parsed = fetch_static(set_override=args.set)
|
||||||
|
meta = parsed["meta"]
|
||||||
|
print(f"set {meta['set']} ({meta['set_name']}), patch {meta['patch']}")
|
||||||
|
for name in ("units", "traits", "items", "augments"):
|
||||||
|
print(f" {name}: {len(parsed[name])}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
23
backend/tft/paths.py
Normal file
23
backend/tft/paths.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
DATA = REPO_ROOT / "data"
|
||||||
|
STATIC = DATA / "static"
|
||||||
|
ARTIFACTS = DATA / "artifacts"
|
||||||
|
MATCHES_DB = DATA / "matches.sqlite"
|
||||||
|
|
||||||
|
|
||||||
|
def static_dir(set_number: int, patch: str) -> Path:
|
||||||
|
return STATIC / f"set{set_number}_{patch}"
|
||||||
|
|
||||||
|
|
||||||
|
def latest_static_pointer() -> Path:
|
||||||
|
return STATIC / "latest.json"
|
||||||
|
|
||||||
|
|
||||||
|
def artifact_dir(set_number: int, build_date: str) -> Path:
|
||||||
|
return ARTIFACTS / f"set{set_number}" / build_date
|
||||||
|
|
||||||
|
|
||||||
|
def latest_artifact_pointer(set_number: int) -> Path:
|
||||||
|
return ARTIFACTS / f"set{set_number}" / "latest.json"
|
||||||
0
backend/tft/staticdata/__init__.py
Normal file
0
backend/tft/staticdata/__init__.py
Normal file
55
backend/tft/staticdata/fetch.py
Normal file
55
backend/tft/staticdata/fetch.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""Download TFT static data from Community Dragon and normalize it."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from tft import paths
|
||||||
|
from tft.staticdata.parse import parse
|
||||||
|
|
||||||
|
CDRAGON = "https://raw.communitydragon.org/latest"
|
||||||
|
METADATA_URL = f"{CDRAGON}/content-metadata.json"
|
||||||
|
TFT_DATA_URL = f"{CDRAGON}/cdragon/tft/en_us.json"
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_static(set_override: int | None = None) -> dict:
|
||||||
|
meta = requests.get(METADATA_URL, timeout=30)
|
||||||
|
meta.raise_for_status()
|
||||||
|
version = meta.json()["version"]
|
||||||
|
patch = ".".join(version.split(".")[:2])
|
||||||
|
|
||||||
|
resp = requests.get(TFT_DATA_URL, timeout=120)
|
||||||
|
resp.raise_for_status()
|
||||||
|
raw = resp.json()
|
||||||
|
|
||||||
|
parsed = parse(raw, set_override=set_override)
|
||||||
|
set_number = parsed["meta"]["set"]
|
||||||
|
|
||||||
|
out_dir = paths.static_dir(set_number, patch)
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
(out_dir / "raw.json").write_text(json.dumps(raw))
|
||||||
|
parsed["meta"]["patch"] = patch
|
||||||
|
parsed["meta"]["game_version"] = version
|
||||||
|
parsed["meta"]["fetched_at"] = date.today().isoformat()
|
||||||
|
for name in ("units", "traits", "items", "augments", "meta"):
|
||||||
|
(out_dir / f"{name}.json").write_text(
|
||||||
|
json.dumps(parsed[name], indent=1, ensure_ascii=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
pointer = {"set": set_number, "patch": patch, "dir": str(out_dir)}
|
||||||
|
paths.latest_static_pointer().write_text(json.dumps(pointer, indent=1))
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def load_static() -> dict:
|
||||||
|
"""Load the most recently fetched static data."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
pointer = json.loads(paths.latest_static_pointer().read_text())
|
||||||
|
static_dir = Path(pointer["dir"])
|
||||||
|
return {
|
||||||
|
name: json.loads((static_dir / f"{name}.json").read_text())
|
||||||
|
for name in ("units", "traits", "items", "augments", "meta")
|
||||||
|
}
|
||||||
90
backend/tft/staticdata/parse.py
Normal file
90
backend/tft/staticdata/parse.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"""Normalize the raw Community Dragon TFT JSON into flat unit/trait/item tables.
|
||||||
|
|
||||||
|
Fails loudly on missing keys — never guess through structure drift.
|
||||||
|
"""
|
||||||
|
|
||||||
|
CDRAGON_GAME = "https://raw.communitydragon.org/latest/game"
|
||||||
|
|
||||||
|
|
||||||
|
def icon_url(asset_path: str) -> str:
|
||||||
|
p = asset_path.lower()
|
||||||
|
for ext in (".tex", ".dds"):
|
||||||
|
if p.endswith(ext):
|
||||||
|
p = p[: -len(ext)] + ".png"
|
||||||
|
return f"{CDRAGON_GAME}/{p}"
|
||||||
|
|
||||||
|
|
||||||
|
def detect_set(raw: dict) -> int:
|
||||||
|
candidates = [
|
||||||
|
int(k) for k, v in raw["sets"].items() if v.get("champions")
|
||||||
|
]
|
||||||
|
if not candidates:
|
||||||
|
raise ValueError("no set with champions found in cdragon data")
|
||||||
|
return max(candidates)
|
||||||
|
|
||||||
|
|
||||||
|
def parse(raw: dict, set_override: int | None = None) -> dict:
|
||||||
|
set_number = set_override if set_override is not None else detect_set(raw)
|
||||||
|
set_data = raw["sets"][str(set_number)]
|
||||||
|
|
||||||
|
units = {}
|
||||||
|
for c in set_data["champions"]:
|
||||||
|
if not c["traits"]:
|
||||||
|
continue # PvE monsters, summons
|
||||||
|
units[c["apiName"]] = {
|
||||||
|
"api_name": c["apiName"],
|
||||||
|
"name": c["name"],
|
||||||
|
"cost": c["cost"],
|
||||||
|
"traits": c["traits"],
|
||||||
|
"role": c.get("role"),
|
||||||
|
"stats": c["stats"],
|
||||||
|
"ability_name": c["ability"]["name"],
|
||||||
|
"ability_variables": {
|
||||||
|
v["name"]: v["value"] for v in c["ability"]["variables"]
|
||||||
|
},
|
||||||
|
"icon": icon_url(c["squareIcon"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
traits = {}
|
||||||
|
trait_name_to_api = {}
|
||||||
|
for t in set_data["traits"]:
|
||||||
|
traits[t["apiName"]] = {
|
||||||
|
"api_name": t["apiName"],
|
||||||
|
"name": t["name"],
|
||||||
|
"breakpoints": [
|
||||||
|
{"min_units": e["minUnits"], "style": e["style"]}
|
||||||
|
for e in t["effects"]
|
||||||
|
],
|
||||||
|
"icon": icon_url(t["icon"]),
|
||||||
|
}
|
||||||
|
trait_name_to_api[t["name"]] = t["apiName"]
|
||||||
|
|
||||||
|
# Champion traits are display names; map them to api names.
|
||||||
|
for u in units.values():
|
||||||
|
u["traits"] = [trait_name_to_api[name] for name in u["traits"]]
|
||||||
|
|
||||||
|
set_prefix = f"TFT{set_number}_"
|
||||||
|
items = {}
|
||||||
|
augments = {}
|
||||||
|
for i in raw["items"]:
|
||||||
|
api = i["apiName"]
|
||||||
|
if not (api.startswith(set_prefix) or api.startswith("TFT_Item_")):
|
||||||
|
continue
|
||||||
|
entry = {
|
||||||
|
"api_name": api,
|
||||||
|
"name": i["name"],
|
||||||
|
"composition": i["composition"],
|
||||||
|
"icon": icon_url(i["icon"]) if i["icon"] else None,
|
||||||
|
}
|
||||||
|
if "Augment" in api:
|
||||||
|
augments[api] = entry
|
||||||
|
else:
|
||||||
|
items[api] = entry
|
||||||
|
|
||||||
|
return {
|
||||||
|
"meta": {"set": set_number, "set_name": set_data["name"]},
|
||||||
|
"units": units,
|
||||||
|
"traits": traits,
|
||||||
|
"items": items,
|
||||||
|
"augments": augments,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user