Generate /players/{account_id}[/{match_id}] locally after POST_GAME via OpenDota; publish to OSS only when public_share is enabled.
Co-authored-by: Cursor <cursoragent@cursor.com>
494 lines
15 KiB
Python
494 lines
15 KiB
Python
"""Post-match player pages: OpenDota → local profile/match JSON (+ optional publish).
|
|
|
|
Triggered from gsi_watch on POST_GAME. Writes:
|
|
|
|
pc/player_pages/{account_id}/profile.json
|
|
pc/player_pages/{account_id}/matches/{match_id}.json
|
|
|
|
When player_pages.public_share is true, also POSTs to the site ingest so OSS
|
|
serves the same paths for /players/{account_id}[/{match_id}].
|
|
|
|
Not used by recommend / item_suggest.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
import json
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from shared.grid import hero_table
|
|
from shared.http_utils import UA, write_json_atomic
|
|
from shared.paths import PC_PLAYER_PAGES
|
|
|
|
OPENDOTA = "https://api.opendota.com/api"
|
|
POST_GAME = "DOTA_GAMERULES_STATE_POST_GAME"
|
|
|
|
_lock = threading.Lock()
|
|
_in_flight: set[str] = set()
|
|
_done: set[str] = set()
|
|
_hero_by_id: dict[int, dict] | None = None
|
|
|
|
|
|
def _utc_now() -> str:
|
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
def _pp_cfg(cfg: dict) -> dict:
|
|
raw = cfg.get("player_pages") or {}
|
|
return raw if isinstance(raw, dict) else {}
|
|
|
|
|
|
def _hero_lookup() -> dict[int, dict]:
|
|
global _hero_by_id
|
|
if _hero_by_id is None:
|
|
out: dict[int, dict] = {}
|
|
for h in hero_table():
|
|
try:
|
|
hid = int(h.get("id") or 0)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if hid > 0:
|
|
out[hid] = h
|
|
_hero_by_id = out
|
|
return _hero_by_id
|
|
|
|
|
|
def _int(v: Any, default: int = 0) -> int:
|
|
try:
|
|
return int(v)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
def _item_ids(player: dict) -> list[int]:
|
|
out: list[int] = []
|
|
for i in range(6):
|
|
iid = _int(player.get(f"item_{i}"), 0)
|
|
if iid > 0:
|
|
out.append(iid)
|
|
return out
|
|
|
|
|
|
def _kda(kills: int, deaths: int, assists: int) -> float:
|
|
return round((kills + assists) / max(deaths, 1), 1)
|
|
|
|
|
|
def _mvp_score(p: dict) -> float:
|
|
"""Simple weighted score for MVP badge (not Valve's formula)."""
|
|
k = _int(p.get("kills"))
|
|
d = _int(p.get("deaths"))
|
|
a = _int(p.get("assists"))
|
|
dmg = _int(p.get("hero_damage"))
|
|
nw = _int(p.get("net_worth"))
|
|
return (k * 1.5 + a + dmg / 1000.0 + nw / 2000.0) / max(d, 1)
|
|
|
|
|
|
def normalize_match(
|
|
match: dict,
|
|
*,
|
|
focus_account_id: int | None = None,
|
|
) -> dict | None:
|
|
"""Build Climperor match-detail JSON from an OpenDota /matches/{id} payload."""
|
|
players_raw = match.get("players")
|
|
if not isinstance(players_raw, list) or not players_raw:
|
|
return None
|
|
match_id = _int(match.get("match_id"), 0)
|
|
if match_id <= 0:
|
|
return None
|
|
|
|
heroes = _hero_lookup()
|
|
radiant_win = bool(match.get("radiant_win"))
|
|
duration = _int(match.get("duration"))
|
|
start_time = match.get("start_time")
|
|
try:
|
|
start_time_i = int(start_time) if start_time is not None else None
|
|
except (TypeError, ValueError):
|
|
start_time_i = None
|
|
|
|
team_kills = [0, 0]
|
|
team_nw = [0, 0]
|
|
team_dmg = [0, 0]
|
|
slim_players: list[dict] = []
|
|
|
|
for p in players_raw:
|
|
if not isinstance(p, dict):
|
|
continue
|
|
slot = _int(p.get("player_slot"))
|
|
is_radiant = slot < 128
|
|
side = 0 if is_radiant else 1
|
|
kills = _int(p.get("kills"))
|
|
deaths = _int(p.get("deaths"))
|
|
assists = _int(p.get("assists"))
|
|
hero_damage = _int(p.get("hero_damage"))
|
|
net_worth = _int(p.get("net_worth"))
|
|
if net_worth <= 0:
|
|
net_worth = _int(p.get("gold")) + _int(p.get("gold_spent"))
|
|
team_kills[side] += kills
|
|
team_nw[side] += net_worth
|
|
team_dmg[side] += hero_damage
|
|
|
|
hero_id = _int(p.get("hero_id"))
|
|
hero = heroes.get(hero_id) or {}
|
|
account_id = p.get("account_id")
|
|
try:
|
|
account_id_i = int(account_id) if account_id is not None else None
|
|
except (TypeError, ValueError):
|
|
account_id_i = None
|
|
|
|
personaname = p.get("personaname")
|
|
if isinstance(personaname, str):
|
|
personaname = personaname.strip() or None
|
|
else:
|
|
personaname = None
|
|
|
|
slim_players.append(
|
|
{
|
|
"account_id": account_id_i,
|
|
"personaname": personaname,
|
|
"hero_id": hero_id,
|
|
"hero_key": hero.get("key"),
|
|
"hero_name_loc": hero.get("name_loc") or hero.get("key"),
|
|
"level": _int(p.get("level")),
|
|
"kills": kills,
|
|
"deaths": deaths,
|
|
"assists": assists,
|
|
"kda": _kda(kills, deaths, assists),
|
|
"hero_damage": hero_damage,
|
|
"net_worth": net_worth,
|
|
"items": _item_ids(p),
|
|
"is_radiant": is_radiant,
|
|
"won": radiant_win if is_radiant else not radiant_win,
|
|
"_mvp": _mvp_score(p),
|
|
"_side": side,
|
|
}
|
|
)
|
|
|
|
if len(slim_players) < 2:
|
|
return None
|
|
|
|
for p in slim_players:
|
|
side = p.pop("_side")
|
|
tk = team_kills[side] or 1
|
|
td = team_dmg[side] or 1
|
|
p["participation"] = round((p["kills"] + p["assists"]) / tk, 3)
|
|
p["damage_share"] = round(p["hero_damage"] / td, 3)
|
|
|
|
mvp = max(slim_players, key=lambda r: r["_mvp"])
|
|
mvp_account = mvp.get("account_id")
|
|
for p in slim_players:
|
|
p["is_mvp"] = bool(mvp_account is not None and p.get("account_id") == mvp_account)
|
|
del p["_mvp"]
|
|
|
|
return {
|
|
"match_id": match_id,
|
|
"start_time": start_time_i,
|
|
"duration": duration,
|
|
"radiant_win": radiant_win,
|
|
"radiant": {"kills": team_kills[0], "net_worth": team_nw[0]},
|
|
"dire": {"kills": team_kills[1], "net_worth": team_nw[1]},
|
|
"mvp_account_id": mvp_account,
|
|
"players": slim_players,
|
|
"focus_account_id": focus_account_id,
|
|
"fetched_at": _utc_now(),
|
|
"source": "opendota",
|
|
}
|
|
|
|
|
|
def match_summary_for_profile(detail: dict, account_id: int) -> dict | None:
|
|
"""One recent-match row for the player homepage."""
|
|
focus = None
|
|
for p in detail.get("players") or []:
|
|
if p.get("account_id") == account_id:
|
|
focus = p
|
|
break
|
|
if focus is None:
|
|
return None
|
|
return {
|
|
"match_id": detail["match_id"],
|
|
"start_time": detail.get("start_time"),
|
|
"duration": detail.get("duration"),
|
|
"won": bool(focus.get("won")),
|
|
"hero_id": focus.get("hero_id"),
|
|
"hero_key": focus.get("hero_key"),
|
|
"hero_name_loc": focus.get("hero_name_loc"),
|
|
"kills": focus.get("kills"),
|
|
"deaths": focus.get("deaths"),
|
|
"assists": focus.get("assists"),
|
|
"kda": focus.get("kda"),
|
|
}
|
|
|
|
|
|
def account_in_match(match: dict, account_id: int) -> bool:
|
|
for p in match.get("players") or []:
|
|
if not isinstance(p, dict):
|
|
continue
|
|
try:
|
|
if int(p.get("account_id")) == account_id:
|
|
return True
|
|
except (TypeError, ValueError):
|
|
continue
|
|
return False
|
|
|
|
|
|
def _fetch_opendota_match(match_id: int) -> dict | None:
|
|
url = f"{OPENDOTA}/matches/{match_id}"
|
|
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=45) as resp:
|
|
data = json.loads(resp.read().decode())
|
|
except urllib.error.HTTPError as e:
|
|
if e.code == 404:
|
|
return None
|
|
raise
|
|
except (urllib.error.URLError, TimeoutError, OSError, ValueError):
|
|
return None
|
|
if not isinstance(data, dict) or not data.get("players"):
|
|
return None
|
|
return data
|
|
|
|
|
|
def profile_path(account_id: int) -> Path:
|
|
return PC_PLAYER_PAGES / str(account_id) / "profile.json"
|
|
|
|
|
|
def match_path(account_id: int, match_id: int) -> Path:
|
|
return PC_PLAYER_PAGES / str(account_id) / "matches" / f"{match_id}.json"
|
|
|
|
|
|
def load_profile(account_id: int) -> dict:
|
|
path = profile_path(account_id)
|
|
if not path.is_file():
|
|
return {
|
|
"account_id": account_id,
|
|
"personaname": None,
|
|
"public_share": False,
|
|
"updated_at": None,
|
|
"recent": [],
|
|
}
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
data = {}
|
|
if not isinstance(data, dict):
|
|
data = {}
|
|
data.setdefault("account_id", account_id)
|
|
data.setdefault("recent", [])
|
|
return data
|
|
|
|
|
|
def upsert_profile(
|
|
account_id: int,
|
|
*,
|
|
summary: dict,
|
|
personaname: str | None,
|
|
public_share: bool,
|
|
recent_limit: int,
|
|
) -> dict:
|
|
profile = load_profile(account_id)
|
|
recent = [r for r in (profile.get("recent") or []) if isinstance(r, dict)]
|
|
mid = summary["match_id"]
|
|
recent = [r for r in recent if r.get("match_id") != mid]
|
|
recent.insert(0, summary)
|
|
profile["recent"] = recent[: max(1, recent_limit)]
|
|
if personaname:
|
|
profile["personaname"] = personaname
|
|
profile["public_share"] = bool(public_share)
|
|
profile["updated_at"] = _utc_now()
|
|
profile["account_id"] = account_id
|
|
write_json_atomic(profile_path(account_id), profile)
|
|
return profile
|
|
|
|
|
|
def publish_remote(
|
|
*,
|
|
account_id: int,
|
|
match_id: int,
|
|
publish_url: str,
|
|
publish_secret: str = "",
|
|
) -> tuple[bool, str]:
|
|
"""POST ingest; returns (ok, message)."""
|
|
url = (publish_url or "").strip()
|
|
if not url:
|
|
return False, "publish_url empty"
|
|
body = json.dumps({"account_id": account_id, "match_id": match_id}).encode("utf-8")
|
|
headers = {
|
|
"User-Agent": UA,
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json",
|
|
}
|
|
secret = (publish_secret or "").strip()
|
|
if secret:
|
|
headers["X-Climperor-Publish-Secret"] = secret
|
|
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
raw = resp.read().decode("utf-8", errors="replace")
|
|
code = getattr(resp, "status", 200)
|
|
except urllib.error.HTTPError as e:
|
|
try:
|
|
raw = e.read().decode("utf-8", errors="replace")
|
|
except Exception: # noqa: BLE001
|
|
raw = str(e)
|
|
return False, f"HTTP {e.code}: {raw[:200]}"
|
|
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
|
return False, str(e)
|
|
if code in (200, 201, 202):
|
|
return True, raw[:200] or f"HTTP {code}"
|
|
return False, f"HTTP {code}: {raw[:200]}"
|
|
|
|
|
|
def process_post_game(
|
|
cfg: dict,
|
|
*,
|
|
match_id: str | int,
|
|
account_id: str | int | None,
|
|
) -> None:
|
|
"""Poll OpenDota, write local pages, optionally publish. Runs in a worker thread."""
|
|
pp = _pp_cfg(cfg)
|
|
if not bool(pp.get("enabled", True)):
|
|
return
|
|
try:
|
|
mid = int(match_id)
|
|
except (TypeError, ValueError):
|
|
print(f"[player_pages] skip bad match_id={match_id!r}", flush=True)
|
|
return
|
|
if mid <= 0:
|
|
return
|
|
try:
|
|
aid = int(account_id) if account_id is not None else 0
|
|
except (TypeError, ValueError):
|
|
aid = 0
|
|
if aid <= 0:
|
|
print(f"[player_pages] skip match {mid}: no accountid (anonymous?)", flush=True)
|
|
return
|
|
|
|
key = f"{aid}:{mid}"
|
|
with _lock:
|
|
if key in _done or key in _in_flight:
|
|
return
|
|
_in_flight.add(key)
|
|
|
|
try:
|
|
attempts = max(1, _int(pp.get("poll_attempts"), 12))
|
|
base = max(5, _int(pp.get("poll_base_seconds"), 30))
|
|
recent_limit = max(1, _int(pp.get("recent_limit"), 30))
|
|
public_share = bool(pp.get("public_share", False))
|
|
print(
|
|
f"[player_pages] fetching match {mid} for account {aid} "
|
|
f"(up to {attempts} tries)…",
|
|
flush=True,
|
|
)
|
|
match: dict | None = None
|
|
for i in range(attempts):
|
|
if i > 0:
|
|
delay = min(300, base * (2 ** min(i - 1, 3)))
|
|
time.sleep(delay)
|
|
try:
|
|
match = _fetch_opendota_match(mid)
|
|
except Exception as e: # noqa: BLE001
|
|
print(f"[player_pages] OpenDota error: {e}", flush=True)
|
|
match = None
|
|
if match and account_in_match(match, aid):
|
|
break
|
|
if match and not account_in_match(match, aid):
|
|
print(
|
|
f"[player_pages] match {mid} has no account {aid} — skip",
|
|
flush=True,
|
|
)
|
|
return
|
|
match = None
|
|
print(
|
|
f"[player_pages] match {mid} not ready ({i + 1}/{attempts})",
|
|
flush=True,
|
|
)
|
|
|
|
if not match:
|
|
print(f"[player_pages] gave up waiting for match {mid}", flush=True)
|
|
return
|
|
|
|
detail = normalize_match(match, focus_account_id=aid)
|
|
if not detail:
|
|
print(f"[player_pages] normalize failed for {mid}", flush=True)
|
|
return
|
|
|
|
write_json_atomic(match_path(aid, mid), detail)
|
|
summary = match_summary_for_profile(detail, aid)
|
|
if not summary:
|
|
print(f"[player_pages] focus player missing in {mid}", flush=True)
|
|
return
|
|
personaname = None
|
|
for p in detail["players"]:
|
|
if p.get("account_id") == aid and p.get("personaname"):
|
|
personaname = p["personaname"]
|
|
break
|
|
profile = upsert_profile(
|
|
aid,
|
|
summary=summary,
|
|
personaname=personaname,
|
|
public_share=public_share,
|
|
recent_limit=recent_limit,
|
|
)
|
|
|
|
local_origin = str(pp.get("local_web_origin") or "http://127.0.0.1:8765").rstrip(
|
|
"/"
|
|
)
|
|
print(
|
|
f"[player_pages] saved {profile_path(aid)} + match {mid}",
|
|
flush=True,
|
|
)
|
|
print(
|
|
f"[player_pages] local: {local_origin}/players/{aid}/{mid}",
|
|
flush=True,
|
|
)
|
|
if not public_share:
|
|
print(
|
|
"[player_pages] private (set player_pages.public_share=true to sync to site)",
|
|
flush=True,
|
|
)
|
|
else:
|
|
pub_url = str(pp.get("publish_url") or "").strip()
|
|
ok, msg = publish_remote(
|
|
account_id=aid,
|
|
match_id=mid,
|
|
publish_url=pub_url,
|
|
publish_secret=str(pp.get("publish_secret") or ""),
|
|
)
|
|
if ok:
|
|
print(f"[player_pages] published: {msg}", flush=True)
|
|
else:
|
|
print(f"[player_pages] publish failed: {msg}", flush=True)
|
|
|
|
with _lock:
|
|
_done.add(key)
|
|
finally:
|
|
with _lock:
|
|
_in_flight.discard(key)
|
|
|
|
|
|
def schedule_post_game(
|
|
cfg: dict,
|
|
*,
|
|
match_id: str | int,
|
|
account_id: str | int | None,
|
|
) -> None:
|
|
"""Fire-and-forget worker; safe to call from the GSI HTTP thread."""
|
|
pp = _pp_cfg(cfg)
|
|
if not bool(pp.get("enabled", True)):
|
|
return
|
|
threading.Thread(
|
|
target=process_post_game,
|
|
kwargs={"cfg": cfg, "match_id": match_id, "account_id": account_id},
|
|
daemon=True,
|
|
name=f"player_pages-{match_id}",
|
|
).start()
|