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
+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: