Files
climperor/web/fetch_hero_portraits.py
T
vosonandCursor d6212169af v0.5.109: hero role toolbar, skill side-by-side layout, taller detail panel.
Move role filters between grid and detail, fix skill video/text layout and detail height jump, unify item dividers, drop OpenDota matchup cross, sync docs and assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 00:48:07 +08:00

152 lines
4.8 KiB
Python

"""Download UI art used by https://www.dota2.com/heroes / dota2.com.cn.
- Landscape hero cards: Steam CDN `heroes/{key}.png`
- Attribute icons: Steam CDN `icons/hero_{strength,agility,...}.png`
- Combat stat icons: dota2.com.cn `herostatic/stats/icon_*.png`
Separate from `templates/cdn/` face crops used for top-bar matching.
Usage:
python fetch_hero_portraits.py
python fetch_hero_portraits.py --force
Writes:
assets/hero_portraits/{key}.png
assets/attr_icons/{str,agi,int,all}.png
assets/ui_icons/icon_{damage,armor,...}.png
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
from shared.http_utils import fetch_hero_keys, http_bytes
from shared.paths import ATTR_ICONS, HERO_PORTRAITS, HEROES_JSON, UI_ICONS
# wide = face headshots; crop = waist-up 3D renders (usually worse in dense grids).
CARD_URL = (
"https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/heroes/{key}.png"
)
CROP_URL = (
"https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/heroes/crops/{key}.png"
)
ATTR_ICON_URL = (
"https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/icons/{name}.png"
)
ATTR_FILES = {
"str": "hero_strength",
"agi": "hero_agility",
"int": "hero_intelligence",
"all": "hero_universal",
}
# Same filenames as https://www.dota2.com.cn/hero/<key> DetailsStats panel.
COMBAT_STAT_ICON_URL = "https://www.dota2.com.cn/herostatic/stats/{name}.png"
COMBAT_STAT_ICONS = (
"icon_damage",
"icon_attack_time",
"icon_attack_range",
"icon_projectile_speed",
"icon_armor",
"icon_magic_resist",
"icon_movement_speed",
"icon_turn_rate",
"icon_vision",
)
def hero_keys() -> list[str]:
if HEROES_JSON.is_file():
rows = json.loads(HEROES_JSON.read_text(encoding="utf-8"))
return [h["key"] for h in rows if h.get("key")]
return fetch_hero_keys()
def fetch_attr_icons(*, force: bool) -> tuple[int, int, int]:
ATTR_ICONS.mkdir(parents=True, exist_ok=True)
ok = skip = fail = 0
for attr, name in ATTR_FILES.items():
out = ATTR_ICONS / f"{attr}.png"
if out.is_file() and not force:
skip += 1
continue
try:
data = http_bytes(ATTR_ICON_URL.format(name=name), timeout=30)
out.write_bytes(data)
ok += 1
print(f" attr {attr} <- {name} ({len(data)} bytes)")
except Exception as e: # noqa: BLE001
fail += 1
print(f" FAIL attr {attr}: {e}")
return ok, skip, fail
def fetch_combat_stat_icons(*, force: bool) -> tuple[int, int, int]:
UI_ICONS.mkdir(parents=True, exist_ok=True)
ok = skip = fail = 0
for name in COMBAT_STAT_ICONS:
out = UI_ICONS / f"{name}.png"
if out.is_file() and not force:
skip += 1
continue
try:
data = http_bytes(COMBAT_STAT_ICON_URL.format(name=name), timeout=30)
out.write_bytes(data)
ok += 1
print(f" combat {name} ({len(data)} bytes)")
except Exception as e: # noqa: BLE001
fail += 1
print(f" FAIL combat {name}: {e}")
return ok, skip, fail
def main() -> None:
ap = argparse.ArgumentParser(description="Download dota2.com/heroes UI art")
ap.add_argument("--force", action="store_true", help="re-download existing files")
ap.add_argument(
"--style",
choices=("crop", "wide"),
default="wide",
help="wide = face headshots for draft grid (default); crop = waist-up renders",
)
args = ap.parse_args()
print("attribute icons...")
a_ok, a_skip, a_fail = fetch_attr_icons(force=args.force)
print("combat stat icons...")
c_ok, c_skip, c_fail = fetch_combat_stat_icons(force=args.force)
HERO_PORTRAITS.mkdir(parents=True, exist_ok=True)
keys = hero_keys()
ok = skip = fail = 0
url_t = CROP_URL if args.style == "crop" else CARD_URL
print(f"hero cards ({args.style})...")
for key in keys:
out = HERO_PORTRAITS / f"{key}.png"
if out.is_file() and not args.force:
skip += 1
continue
try:
data = http_bytes(url_t.format(key=key), timeout=30)
out.write_bytes(data)
ok += 1
print(f" ok {key} ({len(data)} bytes)")
except Exception as e: # noqa: BLE001
fail += 1
print(f" FAIL {key}: {e}")
print(f"attrs: downloaded={a_ok} skipped={a_skip} failed={a_fail} -> {ATTR_ICONS}")
print(
f"combat: downloaded={c_ok} skipped={c_skip} failed={c_fail} -> {UI_ICONS}"
)
print(f"heroes: downloaded={ok} skipped={skip} failed={fail} -> {HERO_PORTRAITS}")
if __name__ == "__main__":
main()