v0.4.1: OSS skill videos, site version footer, and mail contact.

Host ability demos on Aliyun OSS instead of Pages bundles; add patches-page version label and top-right mailto link; improve spirit bear portrait export.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-07-27 20:26:30 +08:00
co-authored by Cursor
parent adb8f303c2
commit 124bdbb7c7
11 changed files with 289 additions and 22 deletions
+93 -2
View File
@@ -33,8 +33,8 @@ import urllib.error
from datetime import datetime, timedelta, timezone
from pathlib import Path
from common import ABILITY_ICONS, DATA, ITEM_ICONS
from http_utils import http_json
from common import ABILITY_ICONS, DATA, HERO_PORTRAITS, ITEM_ICONS
from http_utils import http_bytes, http_json
PATCHES_LIST_URL = "https://www.dota2.com/datafeed/patchnoteslist?language=schinese"
PATCH_NOTES_URL = "https://www.dota2.com/datafeed/patchnotes?version={version}&language=schinese"
@@ -44,6 +44,13 @@ ABILITY_IDS_URL = "https://raw.githubusercontent.com/odota/dotaconstants/master/
ABILITIES_URL = "https://raw.githubusercontent.com/odota/dotaconstants/master/build/abilities.json"
ITEM_ICON_URL = "https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/items/{key}.png"
ABILITY_ICON_URL = "https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/abilities/{key}.png"
# Hero-card CDN (same as fetch_hero_portraits). Non-hero units may only exist at half res.
HERO_CARD_URL = (
"https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/heroes/{key}.png"
)
# Prefer ability art when Valve's hero-card for a unit is tiny (spirit_bear is 128x72).
UNIT_PORTRAIT_FALLBACK_ABILITY = {"spirit_bear": "lone_druid_spirit_bear"}
PORTRAIT_TARGET_SIZE = (256, 144)
OUT = DATA / "patches.json"
HERO_ABILITIES_PATH = DATA / "hero_abilities.json"
@@ -242,6 +249,86 @@ def build_lookup(
return {"items": items, "abilities": abilities, "heroes": heroes}
def _png_size(data: bytes) -> tuple[int, int] | None:
if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n":
return None
import struct
return struct.unpack(">II", data[16:24])
def _cover_resize_png(data: bytes, size: tuple[int, int] = PORTRAIT_TARGET_SIZE) -> bytes:
"""Center-crop / scale image bytes to a 16:9 hero-card PNG."""
import cv2
import numpy as np
arr = np.frombuffer(data, dtype=np.uint8)
img = cv2.imdecode(arr, cv2.IMREAD_UNCHANGED)
if img is None:
raise ValueError("cv2 could not decode image")
if img.ndim == 2:
img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
elif img.shape[2] == 4:
img = cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
h, w = img.shape[:2]
tw, th = size
scale = max(tw / w, th / h)
nw, nh = max(tw, int(round(w * scale))), max(th, int(round(h * scale)))
resized = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_CUBIC)
x0 = max(0, (nw - tw) // 2)
y0 = max(0, (nh - th) // 2)
crop = resized[y0 : y0 + th, x0 : x0 + tw]
ok, buf = cv2.imencode(".png", crop)
if not ok:
raise ValueError("cv2 could not encode png")
return buf.tobytes()
def download_unit_portraits(*, delay: float) -> None:
"""Fetch portraits for UNIT_NAMES into hero_portraits (not in heroes.json).
Valve ships some unit cards at half resolution (spirit_bear 128x72). When a
fallback ability icon is configured, use that art cover-cropped to 256x144
so the patch page matches normal hero cards.
"""
HERO_PORTRAITS.mkdir(parents=True, exist_ok=True)
ok = skip = fail = 0
for unit in UNIT_NAMES.values():
key = unit["key"]
out = HERO_PORTRAITS / f"{key}.png"
if out.is_file():
dims = _png_size(out.read_bytes())
if dims == PORTRAIT_TARGET_SIZE:
skip += 1
continue
try:
fb = UNIT_PORTRAIT_FALLBACK_ABILITY.get(key)
if fb:
raw = http_bytes(ABILITY_ICON_URL.format(key=fb), timeout=30)
data = _cover_resize_png(raw)
src = f"ability:{fb}"
else:
raw = http_bytes(HERO_CARD_URL.format(key=key), timeout=30)
dims = _png_size(raw)
data = (
_cover_resize_png(raw)
if dims and dims != PORTRAIT_TARGET_SIZE
else raw
)
src = "hero-card"
out.write_bytes(data)
ok += 1
print(f" unit portrait {key} <- {src} ({len(data)} bytes)", flush=True)
time.sleep(max(delay, 0.05))
except Exception as e: # noqa: BLE001
fail += 1
print(f" FAIL unit portrait {key}: {e}", flush=True)
print(
f" unit portraits saved={ok} skipped={skip} fail={fail} -> {HERO_PORTRAITS}",
flush=True,
)
def download_referenced_icons(lookup: dict, *, delay: float) -> None:
"""Pull referenced item + ability icons into assets/ (skip existing)."""
from http_utils import download_icons
@@ -266,6 +353,10 @@ def download_referenced_icons(lookup: dict, *, delay: float) -> None:
)
print(f" ability icons saved={saved} skipped={skipped} fail={fail}", flush=True)
# Pseudo-heroes in patchnotes (e.g. 熊灵) are absent from herolist / heroes.json.
print("downloading unit portraits for patch lookup ...", flush=True)
download_unit_portraits(delay=delay)
def load_existing_details() -> dict[str, dict]:
if not OUT.is_file():