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:
+717
-11
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user