"""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()