Ship Steam login, D1 player sync, and cached「我」dashboard.

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>
This commit is contained in:
voson
2026-08-01 01:24:30 +08:00
co-authored by Cursor
parent 4a61aeeb26
commit f5b7011c45
65 changed files with 7304 additions and 552 deletions
+4 -2
View File
@@ -147,10 +147,12 @@
"dump_payloads": true
},
"player_pages": {
"comment": "POST_GAME: poll OpenDota → local pc/player_pages/{account_id}/. public_share=true also POSTs /api/players/publish (OSS). Default private.",
"comment": "POST_GAME: poll OpenDota → local pc/player_pages/{account_id}/. public_share=true POSTs /api/players/publish (queue→D1/R2). recent_limit=recent N matches; recent_days=GSI discovery window; enrich_ttl_seconds=web cache TTL (default 600, same as Pages). Default private.",
"enabled": true,
"public_share": false,
"recent_limit": 30,
"recent_limit": 20,
"recent_days": 14,
"enrich_ttl_seconds": 600,
"poll_attempts": 12,
"poll_base_seconds": 30,
"publish_url": "https://dota2.refining.dev/api/players/publish",
+717 -11
View File
@@ -19,23 +19,53 @@ 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_PLAYER_PAGES
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
@@ -151,6 +181,15 @@ def normalize_match(
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,
@@ -165,29 +204,52 @@ def normalize_match(
"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:
p["is_mvp"] = bool(mvp_account is not None and p.get("account_id") == mvp_account)
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,
@@ -241,20 +303,279 @@ def account_in_match(match: dict, account_id: int) -> bool:
def _fetch_opendota_match(match_id: int) -> dict | None:
url = f"{OPENDOTA}/matches/{match_id}"
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=45) as resp:
data = json.loads(resp.read().decode())
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
if e.code == 404:
if e.code in (403, 404, 429):
return None
raise
except (urllib.error.URLError, TimeoutError, OSError, ValueError):
return None
if not isinstance(data, dict) or not data.get("players"):
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
return data
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:
@@ -286,6 +607,145 @@ def load_profile(account_id: int) -> dict:
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,
*,
@@ -309,6 +769,227 @@ def upsert_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,
@@ -401,11 +1082,20 @@ def process_post_game(
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}skip",
f"[player_pages] match {mid} has no account {aid}"
"will still try enrich from GSI later",
flush=True,
)
return
match = None
break
match = None
print(
f"[player_pages] match {mid} not ready ({i + 1}/{attempts})",
@@ -413,13 +1103,21 @@ def process_post_game(
)
if not match:
print(f"[player_pages] gave up waiting for match {mid}", flush=True)
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)
@@ -431,6 +1129,9 @@ def process_post_game(
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,
@@ -468,6 +1169,11 @@ def process_post_game(
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:
+327
View File
@@ -0,0 +1,327 @@
"""Player homepage aggregates (career / recent20 / heroes / activity / peers).
Pure helpers used by pc/player_pages.py. Not used by recommend / item_suggest.
"""
from __future__ import annotations
from typing import Any
def _int(v: Any, default: int = 0) -> int:
try:
return int(v)
except (TypeError, ValueError):
return default
def _float(v: Any, default: float = 0.0) -> float:
try:
return float(v)
except (TypeError, ValueError):
return default
def kda(kills: int, deaths: int, assists: int) -> float:
return round((kills + assists) / max(deaths, 1), 1)
def winrate(wins: int, losses: int) -> float | None:
total = wins + losses
if total <= 0:
return None
return round(wins / total * 1000) / 10
def aggregate_from_rows(rows: list[dict], *, limit: int | None = None) -> dict:
"""Aggregate match summary rows into a stats snapshot."""
sample = list(rows)
if limit is not None:
sample = sample[: max(0, limit)]
wins = 0
losses = 0
kills = deaths = assists = 0
gpm_sum = xpm_sum = dmg_sum = 0
gpm_n = xpm_n = dmg_n = 0
heroes: list[dict] = []
for r in sample:
if not isinstance(r, dict):
continue
if r.get("won"):
wins += 1
else:
losses += 1
k = _int(r.get("kills"))
d = _int(r.get("deaths"))
a = _int(r.get("assists"))
kills += k
deaths += d
assists += a
if r.get("gpm") is not None:
gpm_sum += _int(r.get("gpm"))
gpm_n += 1
if r.get("xpm") is not None:
xpm_sum += _int(r.get("xpm"))
xpm_n += 1
if r.get("hero_damage") is not None:
dmg_sum += _int(r.get("hero_damage"))
dmg_n += 1
heroes.append(
{
"match_id": _int(r.get("match_id")),
"hero_id": r.get("hero_id"),
"hero_key": r.get("hero_key"),
"hero_name_loc": r.get("hero_name_loc"),
"won": bool(r.get("won")),
}
)
n = wins + losses
out = {
"sample": n,
"wins": wins,
"losses": losses,
"winrate": winrate(wins, losses),
"kills": kills,
"deaths": deaths,
"assists": assists,
"kda": kda(kills, deaths, assists) if n else None,
"avg_kills": round(kills / n, 1) if n else None,
"avg_deaths": round(deaths / n, 1) if n else None,
"avg_assists": round(assists / n, 1) if n else None,
"avg_gpm": round(gpm_sum / gpm_n) if gpm_n else None,
"avg_xpm": round(xpm_sum / xpm_n) if xpm_n else None,
"avg_hero_damage": round(dmg_sum / dmg_n) if dmg_n else None,
"heroes": heroes,
}
return out
def career_from_opendota(wl: dict | None, totals: list | None) -> dict | None:
"""Build career block from OpenDota /wl + /totals. None when empty/private."""
wl = wl if isinstance(wl, dict) else {}
wins = _int(wl.get("win"))
losses = _int(wl.get("lose"))
if wins <= 0 and losses <= 0:
return None
by_field: dict[str, dict] = {}
if isinstance(totals, list):
for row in totals:
if isinstance(row, dict) and row.get("field"):
by_field[str(row["field"])] = row
def sum_of(field: str) -> int:
return _int((by_field.get(field) or {}).get("sum"))
def n_of(field: str) -> int:
return _int((by_field.get(field) or {}).get("n"))
n = wins + losses
kills = sum_of("kills")
deaths = sum_of("deaths")
assists = sum_of("assists")
gpm_n = n_of("gold_per_min")
xpm_n = n_of("xp_per_min")
dmg_n = n_of("hero_damage")
return {
"games": n,
"wins": wins,
"losses": losses,
"winrate": winrate(wins, losses),
"kills": kills,
"deaths": deaths,
"assists": assists,
"kda": kda(kills, deaths, assists) if n else None,
"avg_kills": round(kills / n, 1) if n else None,
"avg_deaths": round(deaths / n, 1) if n else None,
"avg_assists": round(assists / n, 1) if n else None,
"avg_gpm": round(sum_of("gold_per_min") / gpm_n) if gpm_n else None,
"avg_xpm": round(sum_of("xp_per_min") / xpm_n) if xpm_n else None,
"avg_hero_damage": round(sum_of("hero_damage") / dmg_n) if dmg_n else None,
"source": "opendota",
}
def top_heroes_from_opendota(
rows: list | None,
*,
hero_lookup: dict[int, dict],
limit: int = 5,
) -> list[dict]:
if not isinstance(rows, list):
return []
scored: list[tuple[int, dict]] = []
for row in rows:
if not isinstance(row, dict):
continue
games = _int(row.get("games"))
if games <= 0:
continue
hid = _int(row.get("hero_id"))
hero = hero_lookup.get(hid) or {}
wins = _int(row.get("win"))
last = row.get("last_played")
try:
last_i = int(last) if last is not None else None
except (TypeError, ValueError):
last_i = None
scored.append(
(
games,
{
"hero_id": hid or None,
"hero_key": hero.get("key"),
"hero_name_loc": hero.get("name_loc") or hero.get("key"),
"games": games,
"wins": wins,
"winrate": winrate(wins, max(0, games - wins)),
"last_played": last_i,
},
)
)
scored.sort(key=lambda t: (-t[0], -(_int(t[1].get("last_played")))))
return [item for _, item in scored[: max(1, limit)]]
def peers_from_opendota(rows: list | None, *, limit: int = 8) -> list[dict]:
if not isinstance(rows, list):
return []
out: list[dict] = []
for row in rows:
if not isinstance(row, dict):
continue
aid = _int(row.get("account_id"))
games = _int(row.get("games"))
if aid <= 0 or games <= 0:
continue
wins = _int(row.get("win"))
name = row.get("personaname")
if not isinstance(name, str) or not name.strip():
name = f"玩家 {aid}"
avatar = row.get("avatarfull") or row.get("avatar")
if not isinstance(avatar, str):
avatar = None
out.append(
{
"account_id": aid,
"personaname": name.strip(),
"avatar": avatar,
"games": games,
"wins": wins,
"winrate": winrate(wins, max(0, games - wins)),
}
)
if len(out) >= limit:
break
return out
def activity_from_matches(rows: list | None, *, days: int = 180) -> dict | None:
"""Build 180-day activity heatmap + sample highs from OpenDota /matches."""
if not isinstance(rows, list) or not rows:
return None
by_day: dict[str, dict] = {}
max_kills = max_assists = max_gpm = None
wins = losses = 0
for row in rows:
if not isinstance(row, dict):
continue
st = row.get("start_time")
try:
st_i = int(st) if st is not None else 0
except (TypeError, ValueError):
st_i = 0
if st_i <= 0:
continue
# UTC day key YYYY-MM-DD
from datetime import datetime, timezone
day = datetime.fromtimestamp(st_i, tz=timezone.utc).strftime("%Y-%m-%d")
cell = by_day.setdefault(day, {"games": 0, "wins": 0})
cell["games"] += 1
player_slot = _int(row.get("player_slot"))
radiant_win = bool(row.get("radiant_win"))
won = radiant_win if player_slot < 128 else not radiant_win
if won:
cell["wins"] += 1
wins += 1
else:
losses += 1
kills = _int(row.get("kills"))
assists = _int(row.get("assists"))
gpm = _int(row.get("gold_per_min"))
hero_id = _int(row.get("hero_id")) or None
if max_kills is None or kills > max_kills["value"]:
max_kills = {"value": kills, "hero_id": hero_id, "match_id": _int(row.get("match_id"))}
if max_assists is None or assists > max_assists["value"]:
max_assists = {
"value": assists,
"hero_id": hero_id,
"match_id": _int(row.get("match_id")),
}
if gpm > 0 and (max_gpm is None or gpm > max_gpm["value"]):
max_gpm = {"value": gpm, "hero_id": hero_id, "match_id": _int(row.get("match_id"))}
days_list = [
{"date": d, "games": v["games"], "wins": v["wins"]}
for d, v in sorted(by_day.items())
]
return {
"days": days,
"sample": wins + losses,
"wins": wins,
"losses": losses,
"winrate": winrate(wins, losses),
"heatmap": days_list,
"highs": {
"kills": max_kills,
"assists": max_assists,
"gpm": max_gpm,
},
"label": f"最近 {days} 天样本",
}
def merge_availability(
*,
opendota_recent_n: int,
career: dict | None,
steam_history_status: int | None,
fetched_at: str,
) -> dict:
"""Describe whether match history is public / syncing / private."""
od_public = opendota_recent_n > 0 or bool(career and career.get("games"))
steam_allowed = steam_history_status in (1,) # 1 = success
steam_denied = steam_history_status == 15
if od_public:
status = "public"
complete = True
note = None
elif steam_allowed and not od_public:
status = "syncing"
complete = False
note = "Steam 已公开,OpenDota 同步中"
elif steam_denied:
status = "private"
complete = False
note = "未公开比赛数据"
else:
status = "unknown"
complete = False
note = "暂无公开战绩"
return {
"status": status,
"complete": complete,
"opendota_public": od_public,
"steam_history_status": steam_history_status,
"source": "opendota+steam",
"fetched_at": fetched_at,
"note": note,
"stale": False,
}
def should_keep_old_career(old: dict | None, new: dict | None) -> dict | None:
"""HTTP 200 empty must not wipe a previously populated career."""
if new:
return new
if old and isinstance(old, dict) and _int(old.get("games")) > 0:
return old
return new
+50
View File
@@ -0,0 +1,50 @@
"""TTL helpers for player homepage cache-first serving."""
from __future__ import annotations
import sys
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
PC = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PC))
from player_pages import ( # noqa: E402
profile_fetched_at,
profile_has_payload,
profile_is_stale,
)
def _iso_ago(seconds: int) -> str:
t = datetime.now(timezone.utc) - timedelta(seconds=seconds)
return t.strftime("%Y-%m-%dT%H:%M:%SZ")
class PlayerPagesCacheTests(unittest.TestCase):
def test_has_payload_career(self):
self.assertTrue(profile_has_payload({"career": {"games": 10}}))
self.assertFalse(profile_has_payload({"career": {"games": 0}}))
def test_has_payload_recent(self):
self.assertTrue(profile_has_payload({"recent": [{"match_id": 1}]}))
self.assertFalse(profile_has_payload({"recent": []}))
def test_fetched_at_prefers_availability(self):
p = {
"availability": {"fetched_at": "2026-01-01T00:00:00Z"},
"enriched_at": "2026-01-02T00:00:00Z",
}
self.assertEqual(profile_fetched_at(p), "2026-01-01T00:00:00Z")
def test_stale_ttl(self):
fresh = {"enriched_at": _iso_ago(60)}
old = {"enriched_at": _iso_ago(1200)}
self.assertFalse(profile_is_stale(fresh, 600))
self.assertTrue(profile_is_stale(old, 600))
self.assertTrue(profile_is_stale({}, 600))
if __name__ == "__main__":
unittest.main()
+95
View File
@@ -0,0 +1,95 @@
"""Unit tests for player homepage aggregates."""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
PC = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PC))
from player_stats import ( # noqa: E402
aggregate_from_rows,
career_from_opendota,
kda,
merge_availability,
should_keep_old_career,
top_heroes_from_opendota,
winrate,
)
class PlayerStatsTests(unittest.TestCase):
def test_kda_zero_deaths(self):
self.assertEqual(kda(5, 0, 5), 10.0)
def test_winrate_empty(self):
self.assertIsNone(winrate(0, 0))
self.assertEqual(winrate(1, 1), 50.0)
def test_aggregate_recent(self):
rows = [
{"won": True, "kills": 10, "deaths": 2, "assists": 8, "gpm": 500, "hero_id": 1},
{"won": False, "kills": 0, "deaths": 10, "assists": 2, "gpm": 300, "hero_id": 2},
]
out = aggregate_from_rows(rows, limit=20)
self.assertEqual(out["sample"], 2)
self.assertEqual(out["wins"], 1)
self.assertEqual(out["winrate"], 50.0)
self.assertEqual(out["avg_gpm"], 400)
def test_career_empty_private(self):
self.assertIsNone(career_from_opendota({"win": 0, "lose": 0}, []))
def test_career_populated(self):
career = career_from_opendota(
{"win": 10, "lose": 10},
[
{"field": "kills", "n": 20, "sum": 100},
{"field": "deaths", "n": 20, "sum": 50},
{"field": "assists", "n": 20, "sum": 150},
{"field": "gold_per_min", "n": 20, "sum": 8000},
],
)
self.assertEqual(career["games"], 20)
self.assertEqual(career["kda"], 5.0)
self.assertEqual(career["avg_gpm"], 400)
def test_keep_old_career_on_empty(self):
old = {"games": 100, "wins": 50, "losses": 50}
self.assertEqual(should_keep_old_career(old, None), old)
new = {"games": 101, "wins": 51, "losses": 50}
self.assertEqual(should_keep_old_career(old, new), new)
def test_top_heroes(self):
heroes = top_heroes_from_opendota(
[
{"hero_id": 1, "games": 5, "win": 3, "last_played": 100},
{"hero_id": 2, "games": 10, "win": 4, "last_played": 90},
],
hero_lookup={1: {"key": "antimage", "name_loc": "敌法"}, 2: {"key": "axe", "name_loc": "斧王"}},
limit=1,
)
self.assertEqual(len(heroes), 1)
self.assertEqual(heroes[0]["hero_key"], "axe")
def test_availability_syncing(self):
avail = merge_availability(
opendota_recent_n=0,
career=None,
steam_history_status=1,
fetched_at="t",
)
self.assertEqual(avail["status"], "syncing")
private = merge_availability(
opendota_recent_n=0,
career=None,
steam_history_status=15,
fetched_at="t",
)
self.assertEqual(private["status"], "private")
if __name__ == "__main__":
unittest.main()