Players get a fast TTL-backed homepage (local profile / Cloudflare D1) with dense UI polish; login unlocks /home without blocking on every OpenDota refresh. Co-authored-by: Cursor <cursoragent@cursor.com>
1200 lines
39 KiB
Python
1200 lines
39 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 os
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
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_DIR, PC_PLAYER_PAGES
|
|
|
|
try:
|
|
from player_stats import ( # type: ignore
|
|
activity_from_matches,
|
|
aggregate_from_rows,
|
|
career_from_opendota,
|
|
merge_availability,
|
|
peers_from_opendota,
|
|
should_keep_old_career,
|
|
top_heroes_from_opendota,
|
|
)
|
|
except ImportError: # when imported as pc.player_pages
|
|
from pc.player_stats import ( # type: ignore
|
|
activity_from_matches,
|
|
aggregate_from_rows,
|
|
career_from_opendota,
|
|
merge_availability,
|
|
peers_from_opendota,
|
|
should_keep_old_career,
|
|
top_heroes_from_opendota,
|
|
)
|
|
|
|
OPENDOTA = "https://api.opendota.com/api"
|
|
STEAM_API = "https://api.steampowered.com"
|
|
POST_GAME = "DOTA_GAMERULES_STATE_POST_GAME"
|
|
PC_SAMPLES_RAW = PC_DIR / "samples" / "raw"
|
|
_THROTTLE_SEC = 0.4
|
|
DEFAULT_RECENT_LIMIT = 20
|
|
# Align with Pages `/api/players/me` isStale (10 minutes).
|
|
DEFAULT_ENRICH_TTL_SEC = 600
|
|
|
|
_lock = threading.Lock()
|
|
_in_flight: set[str] = set()
|
|
_done: set[str] = set()
|
|
_enrich_bg_inflight: set[int] = 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
|
|
|
|
# OpenDota: same party_id (>0) = stacked; 0/null often means solo or unknown.
|
|
party_raw = p.get("party_id")
|
|
try:
|
|
party_id = int(party_raw) if party_raw is not None else None
|
|
except (TypeError, ValueError):
|
|
party_id = None
|
|
if party_id is not None and party_id <= 0:
|
|
party_id = 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,
|
|
"party_id": party_id,
|
|
"party_label": None,
|
|
"items": _item_ids(p),
|
|
"is_radiant": is_radiant,
|
|
"won": radiant_win if is_radiant else not radiant_win,
|
|
"_mvp": _mvp_score(p),
|
|
"_side": side,
|
|
"_slot": slot,
|
|
}
|
|
)
|
|
|
|
if len(slim_players) < 2:
|
|
return None
|
|
|
|
# Label stacks of 2+ that share party_id (A/B/C…); solo stays null.
|
|
party_counts: dict[int, int] = {}
|
|
for p in slim_players:
|
|
pid = p.get("party_id")
|
|
if isinstance(pid, int) and pid > 0:
|
|
party_counts[pid] = party_counts.get(pid, 0) + 1
|
|
party_labels: dict[int, str] = {}
|
|
for pid, n in sorted(party_counts.items()):
|
|
if n >= 2:
|
|
party_labels[pid] = chr(ord("A") + len(party_labels))
|
|
|
|
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)
|
|
pid = p.get("party_id")
|
|
p["party_label"] = party_labels.get(pid) if isinstance(pid, int) else None
|
|
|
|
# Prefer account_id when present; fall back to slot so anonymous/private
|
|
# lobbies still get an MVP badge (OpenDota often omits account_id).
|
|
mvp = max(slim_players, key=lambda r: r["_mvp"])
|
|
mvp_account = mvp.get("account_id")
|
|
mvp_slot = mvp.get("_slot")
|
|
for p in slim_players:
|
|
if mvp_account is not None:
|
|
p["is_mvp"] = p.get("account_id") == mvp_account
|
|
else:
|
|
p["is_mvp"] = p.get("_slot") == mvp_slot
|
|
del p["_mvp"]
|
|
del p["_slot"]
|
|
|
|
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:
|
|
data = _fetch_opendota_json(f"/matches/{match_id}", timeout=45)
|
|
if not isinstance(data, dict) or not data.get("players"):
|
|
return None
|
|
return data
|
|
|
|
|
|
def _opendota_url(path: str, *, query: dict[str, Any] | None = None) -> str:
|
|
url = f"{OPENDOTA}{path}"
|
|
params = dict(query or {})
|
|
key = (os.environ.get("OPENDOTA_API_KEY") or "").strip()
|
|
if key:
|
|
params["api_key"] = key
|
|
if params:
|
|
url += "?" + urllib.parse.urlencode(params)
|
|
return url
|
|
|
|
|
|
def _fetch_opendota_json(path: str, *, query: dict[str, Any] | None = None, timeout: int = 45) -> Any:
|
|
url = _opendota_url(path, query=query)
|
|
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
return json.loads(resp.read().decode())
|
|
except urllib.error.HTTPError as e:
|
|
if e.code in (403, 404, 429):
|
|
return None
|
|
raise
|
|
except (urllib.error.URLError, TimeoutError, OSError, ValueError):
|
|
return None
|
|
|
|
|
|
def _fetch_opendota_recent(account_id: int) -> list[dict]:
|
|
"""OpenDota /players/{id}/recentMatches (empty when profile is private)."""
|
|
data = _fetch_opendota_json(f"/players/{account_id}/recentMatches")
|
|
if not isinstance(data, list):
|
|
return []
|
|
return [r for r in data if isinstance(r, dict)]
|
|
|
|
|
|
def _fetch_steam_match_history_status(account_id: int) -> int | None:
|
|
"""Steam GetMatchHistory status: 1=ok, 15=private, None=unavailable."""
|
|
key = (os.environ.get("STEAM_API_KEY") or "").strip()
|
|
if not key:
|
|
return None
|
|
q = urllib.parse.urlencode(
|
|
{"key": key, "account_id": account_id, "matches_requested": 1}
|
|
)
|
|
url = f"{STEAM_API}/IDOTA2Match_570/GetMatchHistory/v1/?{q}"
|
|
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=20) as resp:
|
|
data = json.loads(resp.read().decode())
|
|
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, OSError, ValueError):
|
|
return None
|
|
result = (data or {}).get("result") if isinstance(data, dict) else None
|
|
if not isinstance(result, dict):
|
|
return None
|
|
try:
|
|
return int(result.get("status"))
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _recent_from_gsi(since_ts: int) -> set[int]:
|
|
"""Match ids from pc/samples/raw/{match_id}/ with mtime >= since_ts."""
|
|
out: set[int] = set()
|
|
root = PC_SAMPLES_RAW
|
|
if not root.is_dir():
|
|
return out
|
|
for child in root.iterdir():
|
|
if not child.is_dir() or not child.name.isdigit():
|
|
continue
|
|
mid = _int(child.name, 0)
|
|
if mid <= 0:
|
|
continue
|
|
try:
|
|
mtime = int(child.stat().st_mtime)
|
|
except OSError:
|
|
continue
|
|
if mtime >= since_ts:
|
|
out.add(mid)
|
|
return out
|
|
|
|
|
|
def _gsi_self_info(match_id: int, account_id: int) -> tuple[int | None, str | None]:
|
|
"""Best-effort (hero_id, personaname) from samples/raw/{match}/gsi.jsonl."""
|
|
path = PC_SAMPLES_RAW / str(match_id) / "gsi.jsonl"
|
|
if not path.is_file():
|
|
return None, None
|
|
hero_key: str | None = None
|
|
persona: str | None = None
|
|
try:
|
|
with path.open(encoding="utf-8") as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
o = json.loads(line)
|
|
except ValueError:
|
|
continue
|
|
payload = o.get("payload") if isinstance(o.get("payload"), dict) else o
|
|
if not isinstance(payload, dict):
|
|
continue
|
|
player = payload.get("player") or {}
|
|
hero = payload.get("hero") or {}
|
|
try:
|
|
aid = int(player.get("accountid") or 0)
|
|
except (TypeError, ValueError):
|
|
aid = 0
|
|
if aid != account_id:
|
|
continue
|
|
pname = player.get("name")
|
|
if isinstance(pname, str) and pname.strip():
|
|
persona = pname.strip()
|
|
name = hero.get("name")
|
|
if isinstance(name, str) and name.startswith("npc_dota_hero_"):
|
|
hero_key = name[len("npc_dota_hero_") :]
|
|
except OSError:
|
|
return None, None
|
|
hero_id = None
|
|
if hero_key:
|
|
for h in _hero_lookup().values():
|
|
if h.get("key") == hero_key:
|
|
try:
|
|
hero_id = int(h.get("id") or 0) or None
|
|
except (TypeError, ValueError):
|
|
hero_id = None
|
|
break
|
|
return hero_id, persona
|
|
|
|
|
|
def _gsi_self_hero_id(match_id: int, account_id: int) -> int | None:
|
|
hero_id, _ = _gsi_self_info(match_id, account_id)
|
|
return hero_id
|
|
|
|
|
|
def _attach_focus_account(
|
|
match: dict,
|
|
account_id: int,
|
|
hero_id: int | None,
|
|
*,
|
|
personaname: str | None = None,
|
|
) -> bool:
|
|
"""If account hidden, bind account_id onto hero_id player for local pages."""
|
|
if account_in_match(match, account_id):
|
|
if personaname:
|
|
for p in match.get("players") or []:
|
|
if not isinstance(p, dict):
|
|
continue
|
|
try:
|
|
if int(p.get("account_id")) == account_id and not p.get("personaname"):
|
|
p["personaname"] = personaname
|
|
except (TypeError, ValueError):
|
|
continue
|
|
return True
|
|
if not hero_id:
|
|
return False
|
|
for p in match.get("players") or []:
|
|
if not isinstance(p, dict):
|
|
continue
|
|
if _int(p.get("hero_id")) == hero_id and p.get("account_id") in (None, 0, "0"):
|
|
p["account_id"] = account_id
|
|
if personaname and not p.get("personaname"):
|
|
p["personaname"] = personaname
|
|
return True
|
|
return False
|
|
|
|
|
|
def _fetch_opendota_player(account_id: int) -> dict:
|
|
"""OpenDota /players/{id} → personaname / rank_tier / leaderboard_rank / avatar."""
|
|
data = _fetch_opendota_json(f"/players/{account_id}", timeout=30)
|
|
if not isinstance(data, dict):
|
|
return {}
|
|
profile = data.get("profile") if isinstance(data.get("profile"), dict) else data
|
|
name = profile.get("personaname") if isinstance(profile, dict) else None
|
|
out: dict = {}
|
|
if isinstance(name, str) and name.strip():
|
|
out["personaname"] = name.strip()
|
|
avatar = None
|
|
if isinstance(profile, dict):
|
|
avatar = profile.get("avatarfull") or profile.get("avatarmedium") or profile.get("avatar")
|
|
if isinstance(avatar, str) and avatar.strip():
|
|
out["avatar"] = avatar.strip()
|
|
try:
|
|
tier = int(data.get("rank_tier")) if data.get("rank_tier") is not None else None
|
|
except (TypeError, ValueError):
|
|
tier = None
|
|
if tier and tier >= 10:
|
|
out["rank_tier"] = tier
|
|
try:
|
|
lb = (
|
|
int(data.get("leaderboard_rank"))
|
|
if data.get("leaderboard_rank") is not None
|
|
else None
|
|
)
|
|
except (TypeError, ValueError):
|
|
lb = None
|
|
if lb and lb > 0:
|
|
out["leaderboard_rank"] = lb
|
|
if data.get("profile") and isinstance(data["profile"], dict):
|
|
out["fh_unavailable"] = bool(data["profile"].get("fh_unavailable"))
|
|
return out
|
|
|
|
|
|
def _fetch_opendota_persona(account_id: int) -> str | None:
|
|
"""Steam persona from OpenDota /players/{id} (None if private/missing)."""
|
|
return _fetch_opendota_player(account_id).get("personaname")
|
|
|
|
|
|
def fill_player_personanames(detail: dict) -> None:
|
|
"""Fill missing personaname for players that have a public account_id."""
|
|
players = detail.get("players")
|
|
if not isinstance(players, list):
|
|
return
|
|
for i, p in enumerate(players):
|
|
if not isinstance(p, dict):
|
|
continue
|
|
if p.get("personaname"):
|
|
continue
|
|
aid = p.get("account_id")
|
|
try:
|
|
aid_i = int(aid) if aid is not None else 0
|
|
except (TypeError, ValueError):
|
|
aid_i = 0
|
|
if aid_i <= 0:
|
|
continue
|
|
if i > 0:
|
|
time.sleep(_THROTTLE_SEC)
|
|
persona = _fetch_opendota_persona(aid_i)
|
|
if persona:
|
|
p["personaname"] = persona
|
|
|
|
|
|
def summary_from_recent_row(row: dict) -> dict | None:
|
|
"""Build a profile.recent row from OpenDota recentMatches entry."""
|
|
mid = _int(row.get("match_id"), 0)
|
|
if mid <= 0:
|
|
return None
|
|
hero_id = _int(row.get("hero_id"), 0)
|
|
hero = _hero_lookup().get(hero_id) or {}
|
|
kills = _int(row.get("kills"))
|
|
deaths = _int(row.get("deaths"))
|
|
assists = _int(row.get("assists"))
|
|
player_slot = _int(row.get("player_slot"))
|
|
radiant_win = bool(row.get("radiant_win"))
|
|
is_radiant = player_slot < 128
|
|
start_time = row.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
|
|
gpm = row.get("gold_per_min")
|
|
xpm = row.get("xp_per_min")
|
|
dmg = row.get("hero_damage")
|
|
return {
|
|
"match_id": mid,
|
|
"start_time": start_time_i,
|
|
"duration": _int(row.get("duration")),
|
|
"won": radiant_win if is_radiant else not radiant_win,
|
|
"hero_id": hero_id or None,
|
|
"hero_key": hero.get("key"),
|
|
"hero_name_loc": hero.get("name_loc") or hero.get("key"),
|
|
"kills": kills,
|
|
"deaths": deaths,
|
|
"assists": assists,
|
|
"kda": _kda(kills, deaths, assists),
|
|
"gpm": _int(gpm) if gpm is not None else None,
|
|
"xpm": _int(xpm) if xpm is not None else None,
|
|
"hero_damage": _int(dmg) if dmg is not None else None,
|
|
"game_mode": _int(row.get("game_mode")) if row.get("game_mode") is not None else None,
|
|
"lobby_type": _int(row.get("lobby_type")) if row.get("lobby_type") is not None else None,
|
|
}
|
|
|
|
|
|
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 enrich_ttl_seconds(cfg: dict) -> int:
|
|
pp = _pp_cfg(cfg)
|
|
return max(30, _int(pp.get("enrich_ttl_seconds"), DEFAULT_ENRICH_TTL_SEC))
|
|
|
|
|
|
def profile_fetched_at(profile: dict | None) -> str | None:
|
|
if not isinstance(profile, dict):
|
|
return None
|
|
avail = profile.get("availability")
|
|
if isinstance(avail, dict) and avail.get("fetched_at"):
|
|
return str(avail["fetched_at"])
|
|
if profile.get("enriched_at"):
|
|
return str(profile["enriched_at"])
|
|
if profile.get("updated_at"):
|
|
return str(profile["updated_at"])
|
|
return None
|
|
|
|
|
|
def profile_has_payload(profile: dict | None) -> bool:
|
|
"""True when cached profile is worth serving without a full OpenDota round-trip."""
|
|
if not isinstance(profile, dict):
|
|
return False
|
|
career = profile.get("career")
|
|
if isinstance(career, dict) and _int(career.get("games"), 0) > 0:
|
|
return True
|
|
recent = profile.get("recent")
|
|
if isinstance(recent, list) and any(isinstance(r, dict) for r in recent):
|
|
return True
|
|
r20 = profile.get("recent_20")
|
|
if isinstance(r20, dict) and _int(r20.get("sample"), 0) > 0:
|
|
return True
|
|
return False
|
|
|
|
|
|
def profile_is_stale(profile: dict | None, ttl_sec: int) -> bool:
|
|
fetched = profile_fetched_at(profile)
|
|
if not fetched:
|
|
return True
|
|
try:
|
|
# Accept trailing Z.
|
|
ts = datetime.fromisoformat(fetched.replace("Z", "+00:00")).timestamp()
|
|
except ValueError:
|
|
return True
|
|
return (time.time() - ts) > max(30, int(ttl_sec))
|
|
|
|
|
|
def _schedule_enrich_background(
|
|
cfg: dict,
|
|
account_id: int,
|
|
*,
|
|
include_gsi: bool = True,
|
|
) -> None:
|
|
"""Fire-and-forget local refresh (mirrors production Queue)."""
|
|
with _lock:
|
|
if account_id in _enrich_bg_inflight:
|
|
return
|
|
_enrich_bg_inflight.add(account_id)
|
|
|
|
def _run() -> None:
|
|
try:
|
|
enrich_profile_recent(cfg, account_id, include_gsi=include_gsi)
|
|
except Exception as e: # noqa: BLE001
|
|
print(f"[player_pages] background enrich {account_id}: {e}", flush=True)
|
|
finally:
|
|
with _lock:
|
|
_enrich_bg_inflight.discard(account_id)
|
|
|
|
threading.Thread(
|
|
target=_run,
|
|
name=f"player-enrich-{account_id}",
|
|
daemon=True,
|
|
).start()
|
|
|
|
|
|
def get_profile_for_web(
|
|
cfg: dict,
|
|
account_id: int | str,
|
|
*,
|
|
force: bool = False,
|
|
include_gsi: bool = True,
|
|
) -> dict:
|
|
"""Serve player homepage: cache-first, first miss sync, stale refresh async.
|
|
|
|
Matches production semantics: first load may wait on OpenDota; later loads
|
|
return local profile.json immediately and refresh in the background when
|
|
older than enrich_ttl_seconds (default 600).
|
|
"""
|
|
try:
|
|
aid = int(account_id)
|
|
except (TypeError, ValueError):
|
|
aid = 0
|
|
if aid <= 0:
|
|
return {
|
|
"account_id": 0,
|
|
"personaname": None,
|
|
"public_share": False,
|
|
"updated_at": None,
|
|
"recent": [],
|
|
"error": "bad account_id",
|
|
}
|
|
|
|
ttl = enrich_ttl_seconds(cfg)
|
|
cached = load_profile(aid)
|
|
has_payload = profile_has_payload(cached)
|
|
stale = profile_is_stale(cached, ttl)
|
|
|
|
if has_payload and not force and not stale:
|
|
out = dict(cached)
|
|
out["stale"] = False
|
|
avail = out.get("availability")
|
|
if isinstance(avail, dict):
|
|
avail = dict(avail)
|
|
avail["stale"] = False
|
|
out["availability"] = avail
|
|
return out
|
|
|
|
if has_payload and not force and stale:
|
|
_schedule_enrich_background(cfg, aid, include_gsi=include_gsi)
|
|
out = dict(cached)
|
|
out["stale"] = True
|
|
avail = out.get("availability")
|
|
if isinstance(avail, dict):
|
|
avail = dict(avail)
|
|
avail["stale"] = True
|
|
out["availability"] = avail
|
|
else:
|
|
out["availability"] = {
|
|
"status": "unknown",
|
|
"note": "刷新中",
|
|
"complete": False,
|
|
"stale": True,
|
|
"fetched_at": profile_fetched_at(cached),
|
|
}
|
|
return out
|
|
|
|
# Cold miss (or force): block once so the first page view gets real data.
|
|
return enrich_profile_recent(cfg, aid, include_gsi=include_gsi)
|
|
|
|
|
|
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 enrich_profile_recent(
|
|
cfg: dict,
|
|
account_id: int | str,
|
|
*,
|
|
days: int | None = None,
|
|
include_gsi: bool = True,
|
|
) -> dict:
|
|
"""Refresh profile: recent 20 + career/top heroes/peers/activity.
|
|
|
|
OpenDota is the stats source. Optional GSI raw dirs fill private lobbies.
|
|
Empty OpenDota responses do not wipe previously stored career stats.
|
|
"""
|
|
pp = _pp_cfg(cfg)
|
|
try:
|
|
aid = int(account_id)
|
|
except (TypeError, ValueError):
|
|
aid = 0
|
|
if aid <= 0:
|
|
return {
|
|
"account_id": 0,
|
|
"personaname": None,
|
|
"public_share": False,
|
|
"updated_at": None,
|
|
"recent": [],
|
|
"error": "bad account_id",
|
|
}
|
|
|
|
# Legacy `days` still gates GSI discovery window; list itself is recent N.
|
|
window_days = max(1, _int(days if days is not None else pp.get("recent_days"), 14))
|
|
recent_limit = max(1, _int(pp.get("recent_limit"), DEFAULT_RECENT_LIMIT))
|
|
public_share = bool(pp.get("public_share", False))
|
|
since_ts = int(time.time()) - window_days * 86400
|
|
|
|
by_mid: dict[int, dict] = {}
|
|
try:
|
|
rows = _fetch_opendota_recent(aid)
|
|
except Exception as e: # noqa: BLE001
|
|
print(f"[player_pages] recentMatches error for {aid}: {e}", flush=True)
|
|
rows = []
|
|
for row in rows:
|
|
summary = summary_from_recent_row(row)
|
|
if summary:
|
|
by_mid[summary["match_id"]] = summary
|
|
|
|
gsi_mids: set[int] = set()
|
|
if include_gsi:
|
|
gsi_mids = _recent_from_gsi(since_ts)
|
|
|
|
need_fetch = sorted(m for m in gsi_mids if m not in by_mid)
|
|
for i, mid in enumerate(need_fetch):
|
|
if i > 0:
|
|
time.sleep(_THROTTLE_SEC)
|
|
try:
|
|
match = _fetch_opendota_match(mid)
|
|
except Exception as e: # noqa: BLE001
|
|
print(f"[player_pages] enrich match {mid}: {e}", flush=True)
|
|
continue
|
|
if not match:
|
|
continue
|
|
hero_id, gsi_name = _gsi_self_info(mid, aid)
|
|
if not _attach_focus_account(match, aid, hero_id, personaname=gsi_name):
|
|
continue
|
|
detail = normalize_match(match, focus_account_id=aid)
|
|
if not detail:
|
|
continue
|
|
fill_player_personanames(detail)
|
|
summary = match_summary_for_profile(detail, aid)
|
|
if summary:
|
|
by_mid[mid] = summary
|
|
if not match_path(aid, mid).is_file():
|
|
write_json_atomic(match_path(aid, mid), detail)
|
|
|
|
profile = load_profile(aid)
|
|
existing = [r for r in (profile.get("recent") or []) if isinstance(r, dict)]
|
|
for r in existing:
|
|
mid = _int(r.get("match_id"), 0)
|
|
if mid > 0 and mid not in by_mid:
|
|
by_mid[mid] = r
|
|
|
|
merged = sorted(
|
|
by_mid.values(),
|
|
key=lambda r: (_int(r.get("start_time"), 0), _int(r.get("match_id"), 0)),
|
|
reverse=True,
|
|
)
|
|
profile["recent"] = merged[:recent_limit]
|
|
profile["account_id"] = aid
|
|
profile["public_share"] = public_share
|
|
|
|
meta = _fetch_opendota_player(aid)
|
|
if meta.get("personaname"):
|
|
profile["personaname"] = meta["personaname"]
|
|
if meta.get("avatar"):
|
|
profile["avatar"] = meta["avatar"]
|
|
if meta.get("rank_tier"):
|
|
profile["rank_tier"] = meta["rank_tier"]
|
|
if meta.get("leaderboard_rank"):
|
|
profile["leaderboard_rank"] = meta["leaderboard_rank"]
|
|
|
|
wl = _fetch_opendota_json(f"/players/{aid}/wl")
|
|
totals = _fetch_opendota_json(f"/players/{aid}/totals")
|
|
heroes = _fetch_opendota_json(f"/players/{aid}/heroes")
|
|
peers = _fetch_opendota_json(f"/players/{aid}/peers")
|
|
matches_180 = _fetch_opendota_json(
|
|
f"/players/{aid}/matches",
|
|
query={"date": 180, "significant": 0},
|
|
)
|
|
steam_status = _fetch_steam_match_history_status(aid)
|
|
|
|
new_career = career_from_opendota(
|
|
wl if isinstance(wl, dict) else None,
|
|
totals if isinstance(totals, list) else None,
|
|
)
|
|
profile["career"] = should_keep_old_career(profile.get("career"), new_career)
|
|
profile["recent_20"] = aggregate_from_rows(profile["recent"], limit=20)
|
|
profile["top_heroes"] = top_heroes_from_opendota(
|
|
heroes if isinstance(heroes, list) else None,
|
|
hero_lookup=_hero_lookup(),
|
|
limit=5,
|
|
) or profile.get("top_heroes") or []
|
|
new_peers = peers_from_opendota(peers if isinstance(peers, list) else None, limit=8)
|
|
if new_peers:
|
|
profile["peers"] = new_peers
|
|
elif not isinstance(profile.get("peers"), list):
|
|
profile["peers"] = []
|
|
new_activity = activity_from_matches(
|
|
matches_180 if isinstance(matches_180, list) else None,
|
|
days=180,
|
|
)
|
|
if new_activity:
|
|
profile["activity_180"] = new_activity
|
|
elif not isinstance(profile.get("activity_180"), dict):
|
|
profile["activity_180"] = None
|
|
|
|
fetched = _utc_now()
|
|
profile["availability"] = merge_availability(
|
|
opendota_recent_n=len(rows),
|
|
career=profile.get("career"),
|
|
steam_history_status=steam_status,
|
|
fetched_at=fetched,
|
|
)
|
|
if meta.get("fh_unavailable") and profile["availability"]["status"] == "unknown":
|
|
profile["availability"]["status"] = "private"
|
|
profile["availability"]["note"] = "未公开比赛数据"
|
|
profile["updated_at"] = fetched
|
|
profile["enriched_at"] = fetched
|
|
profile["stale"] = False
|
|
if isinstance(profile.get("availability"), dict):
|
|
profile["availability"]["stale"] = False
|
|
write_json_atomic(profile_path(aid), profile)
|
|
print(
|
|
f"[player_pages] enrich account {aid}: {len(profile['recent'])} recent "
|
|
f"(limit={recent_limit}, gsi={len(gsi_mids)}, rank={profile.get('rank_tier')}, "
|
|
f"avail={profile['availability'].get('status')})",
|
|
flush=True,
|
|
)
|
|
return profile
|
|
|
|
|
|
def ensure_match_detail(
|
|
cfg: dict,
|
|
account_id: int | str,
|
|
match_id: int | str,
|
|
) -> tuple[dict | None, str]:
|
|
"""Return local match JSON; fetch+normalize from OpenDota if missing.
|
|
|
|
Returns (detail, error_message). error_message empty on success.
|
|
"""
|
|
pp = _pp_cfg(cfg)
|
|
try:
|
|
aid = int(account_id)
|
|
mid = int(match_id)
|
|
except (TypeError, ValueError):
|
|
return None, "bad account_id or match_id"
|
|
if aid <= 0 or mid <= 0:
|
|
return None, "bad account_id or match_id"
|
|
|
|
path = match_path(aid, mid)
|
|
if path.is_file():
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError) as e:
|
|
return None, str(e)
|
|
if isinstance(data, dict) and data.get("players"):
|
|
return data, ""
|
|
|
|
try:
|
|
match = _fetch_opendota_match(mid)
|
|
except Exception as e: # noqa: BLE001
|
|
return None, f"opendota: {e}"
|
|
if not match:
|
|
return None, "match not ready on OpenDota"
|
|
|
|
hero_id, gsi_name = _gsi_self_info(mid, aid)
|
|
if not _attach_focus_account(match, aid, hero_id, personaname=gsi_name):
|
|
return None, "account not in match (private or wrong id)"
|
|
|
|
detail = normalize_match(match, focus_account_id=aid)
|
|
if not detail:
|
|
return None, "normalize failed"
|
|
fill_player_personanames(detail)
|
|
|
|
write_json_atomic(path, detail)
|
|
summary = match_summary_for_profile(detail, aid)
|
|
if summary:
|
|
personaname = gsi_name
|
|
for p in detail["players"]:
|
|
if p.get("account_id") == aid and p.get("personaname"):
|
|
personaname = p["personaname"]
|
|
break
|
|
if not personaname:
|
|
personaname = _fetch_opendota_persona(aid)
|
|
upsert_profile(
|
|
aid,
|
|
summary=summary,
|
|
personaname=personaname,
|
|
public_share=bool(pp.get("public_share", False)),
|
|
recent_limit=max(1, _int(pp.get("recent_limit"), 30)),
|
|
)
|
|
return detail, ""
|
|
|
|
|
|
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):
|
|
hero_id, gsi_name = _gsi_self_info(mid, aid)
|
|
if _attach_focus_account(match, aid, hero_id, personaname=gsi_name):
|
|
print(
|
|
f"[player_pages] match {mid}: attached account via GSI hero",
|
|
flush=True,
|
|
)
|
|
break
|
|
print(
|
|
f"[player_pages] match {mid} has no account {aid} — "
|
|
"will still try enrich from GSI later",
|
|
flush=True,
|
|
)
|
|
match = None
|
|
break
|
|
match = None
|
|
print(
|
|
f"[player_pages] match {mid} not ready ({i + 1}/{attempts})",
|
|
flush=True,
|
|
)
|
|
|
|
if not match:
|
|
print(
|
|
f"[player_pages] match {mid} detail unavailable; enriching recent…",
|
|
flush=True,
|
|
)
|
|
try:
|
|
enrich_profile_recent(cfg, aid, include_gsi=True)
|
|
except Exception as e: # noqa: BLE001
|
|
print(f"[player_pages] enrich failed: {e}", 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
|
|
fill_player_personanames(detail)
|
|
|
|
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
|
|
if not personaname:
|
|
_, gsi_name = _gsi_self_info(mid, aid)
|
|
personaname = gsi_name or _fetch_opendota_persona(aid)
|
|
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)
|
|
|
|
try:
|
|
enrich_profile_recent(cfg, aid, include_gsi=True)
|
|
except Exception as e: # noqa: BLE001
|
|
print(f"[player_pages] enrich after post-game failed: {e}", 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()
|