добавил сезонную статистику и карьерную статистику на игроков

This commit is contained in:
2025-11-05 13:58:08 +03:00
parent aaa4505380
commit a0520880ee

View File

@@ -310,6 +310,18 @@ def stop_offline_threads():
logger.info("[threads] OFFLINE threads stopped") logger.info("[threads] OFFLINE threads stopped")
def has_full_game_ready() -> bool:
game = latest_data.get("game")
if not game:
return False
payload = game.get("data", game)
return (
isinstance(payload, dict)
and isinstance(payload.get("data"), dict)
and isinstance(payload["data"].get("result"), dict)
and "teams" in payload["data"]["result"]
)
# Функция запускаемая в потоках # Функция запускаемая в потоках
def get_data_from_API( def get_data_from_API(
name: str, name: str,
@@ -321,12 +333,10 @@ def get_data_from_API(
did_first_fetch = False did_first_fetch = False
while not stop_event.is_set(): while not stop_event.is_set():
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
if stop_when_live and globals().get("STATUS") == "live" and did_first_fetch: if stop_when_live and globals().get("STATUS") == "live" and has_full_game_ready():
logger.info( logger.info(f"{[{current_time}]} [{name}] stopping because STATUS='live' and full game is ready")
f"{[{current_time}]} [{name}] stopping because STATUS='live' and first fetch done"
)
break break
start = time.time()
try: try:
value = requests.get(url, timeout=5).json() value = requests.get(url, timeout=5).json()
did_first_fetch = True # помечаем, что один заход сделали did_first_fetch = True # помечаем, что один заход сделали
@@ -370,11 +380,10 @@ def get_data_from_API(
while slept < sleep_time: while slept < sleep_time:
if stop_event.is_set(): if stop_event.is_set():
break break
if stop_when_live and globals().get("STATUS") == "live" and did_first_fetch: if stop_when_live and globals().get("STATUS") == "live" and has_full_game_ready():
logger.info( logger.info(f"[{name}] stopping during sleep because STATUS='live' and full game is ready")
f"[{name}] stopping during sleep because STATUS='live' and first fetch done"
)
return return
time.sleep(1) time.sleep(1)
slept += 1 slept += 1
# если запрос занял дольше — просто сразу следующую итерацию # если запрос занял дольше — просто сразу следующую итерацию
@@ -575,19 +584,19 @@ def results_consumer():
# чтобы /status видел "живость" раз в 5 минут независимо от полноты JSON. # чтобы /status видел "живость" раз в 5 минут независимо от полноты JSON.
if globals().get("STATUS") != "live": if globals().get("STATUS") != "live":
latest_data["game"] = {"ts": msg["ts"], "data": payload} latest_data["game"] = {"ts": msg["ts"], "data": payload}
# можно залогировать для отладки:
logger.debug("results_consumer: pre-live game → updated (full=%s)", is_full) logger.debug("results_consumer: pre-live game → updated (full=%s)", is_full)
else:
# ✅ если игры ещё НЕТ в кэше — примем ПЕРВЫЙ game даже неполный,
# чтобы box-score/play-by-play могли его дорастить
if is_full or not has_game_already:
latest_data["game"] = {"ts": msg["ts"], "data": payload}
logger.debug("results_consumer: LIVE → stored (full=%s, had=%s)", is_full, has_game_already)
else:
logger.debug("results_consumer: LIVE & partial game → keep previous one")
# 2) Когда матч УЖЕ online (STATUS == 'live'): # 2) Когда матч УЖЕ online (STATUS == 'live'):
# - поток 'game' в live-режиме погаснет сам (stop_when_live=True), # - поток 'game' в live-режиме погаснет сам (stop_when_live=True),
# но если вдруг что-то долетит, кладём только полный JSON. # но если вдруг что-то долетит, кладём только полный JSON.
else:
if is_full:
latest_data["game"] = {"ts": msg["ts"], "data": payload}
logger.debug("results_consumer: LIVE & full game → replaced")
else:
# Ничего не трогаем, чтобы не затереть полноценные данные пустотой.
logger.debug("results_consumer: LIVE & partial game → keep previous one")
continue continue
# # game неполный # # game неполный
# if not has_game_already: # if not has_game_already:
@@ -1010,7 +1019,7 @@ def format_time(seconds: float | int) -> str:
@app.get("/team1") @app.get("/team1")
async def team1(): async def team1():
game = get_latest_game_safe() game = get_latest_game_safe("game")
if not game: if not game:
raise HTTPException(status_code=503, detail="game data not ready") raise HTTPException(status_code=503, detail="game data not ready")
return await team("team1") return await team("team1")
@@ -1018,7 +1027,7 @@ async def team1():
@app.get("/team2") @app.get("/team2")
async def team2(): async def team2():
game = get_latest_game_safe() game = get_latest_game_safe("game")
if not game: if not game:
raise HTTPException(status_code=503, detail="game data not ready") raise HTTPException(status_code=503, detail="game data not ready")
return await team("team2") return await team("team2")
@@ -1247,7 +1256,7 @@ async def status(request: Request):
@app.get("/scores") @app.get("/scores")
async def scores(): async def scores():
game = get_latest_game_safe() game = get_latest_game_safe("game")
if not game: if not game:
# игры ещё нет или пришёл только частичный ответ # игры ещё нет или пришёл только частичный ответ
# отдаём пустую структуру, чтобы фронт не падал # отдаём пустую структуру, чтобы фронт не падал
@@ -1309,13 +1318,13 @@ async def top_sorted_team(data):
return top_sorted_team return top_sorted_team
def get_latest_game_safe(): def get_latest_game_safe(name:str):
""" """
Безопасно достаём актуальный game из latest_data. Безопасно достаём актуальный game из latest_data.
Возвращаем None, если структура ещё не готова или прилетел "плохой" game Возвращаем None, если структура ещё не готова или прилетел "плохой" game
(например, с {"status": "no-status"} без data/result). (например, с {"status": "no-status"} без data/result).
""" """
game = latest_data.get("game") game = latest_data.get(name)
if not game: if not game:
return None return None
@@ -1338,33 +1347,118 @@ def get_latest_game_safe():
return game return game
def format_time(seconds: float | int) -> str:
"""
Форматирует время в секундах в строку "M:SS".
Args:
seconds (float | int): Количество секунд.
Returns:
str: Время в формате "M:SS".
"""
try:
total_seconds = int(float(seconds))
minutes = total_seconds // 60
sec = total_seconds % 60
return f"{minutes}:{sec:02}"
except (ValueError, TypeError):
return "0:00"
def _pick_last_avg_and_sum(stats_list: list) -> tuple[dict, dict]:
"""Возвращает (season_sum, season_avg) из seasonStats. Безопасно при пустых данных."""
if not isinstance(stats_list, list) or len(stats_list) == 0:
return {}, {}
# В JSON конец массива: ... {"class":"Sum"}, {"class":"Avg"}
last = stats_list[-1] if stats_list else None
prev = stats_list[-2] if len(stats_list) >= 2 else None
season_avg = last.get("stats", {}) if isinstance(last, dict) and str(last.get("class")).lower() == "avg" else {}
season_sum = prev.get("stats", {}) if isinstance(prev, dict) and str(prev.get("class")).lower() == "sum" else {}
# Бывают инверсии порядка (на всякий случай): попробуем найти явно
if not season_avg or not season_sum:
for x in reversed(stats_list):
if isinstance(x, dict) and str(x.get("class")).lower() == "avg" and not season_avg:
season_avg = x.get("stats", {}) or {}
if isinstance(x, dict) and str(x.get("class")).lower() == "sum" and not season_sum:
season_sum = x.get("stats", {}) or {}
if season_avg and season_sum:
break
return season_sum, season_avg
def _pick_career_sum_and_avg(carrier_list: list) -> tuple[dict, dict]:
"""Возвращает (career_sum, career_avg) из carrier. В API встречаются блоки с class: Normal/Sum/Avg."""
if not isinstance(carrier_list, list) or len(carrier_list) == 0:
return {}, {}
career_sum, career_avg = {}, {}
# Ищем явные «Sum» и «Avg»
for x in reversed(carrier_list):
if isinstance(x, dict):
cls = str(x.get("class", "")).lower()
stats = x.get("stats", {}) or {}
if cls == "sum" and not career_sum:
career_sum = stats
elif cls == "avg" and not career_avg:
career_avg = stats
if career_sum and career_avg:
break
# Если «Avg» нет (часто для карьеры бывает только Normal/Sum) — ок, оставим пустым
return career_sum, career_avg
def _as_int(v, default=0):
try:
# в JSON часто строки; пустые строки -> 0
if v in ("", None):
return default
return int(float(v))
except Exception:
return default
def _safe(d: dict) -> dict:
return d if isinstance(d, dict) else {}
async def team(who: str): async def team(who: str):
""" """
Возвращает данные по команде (team1 / team2) из актуального game. Возвращает данные по команде (team1 / team2) из актуального game.
Защищена от ситуации, когда latest_data["game"] ещё не прогрелся Защищена от ситуации, когда latest_data["game"] ещё не прогрелся
или в него прилетел "плохой" ответ от API. или в него прилетел "плохой" ответ от API.
""" """
game = get_latest_game_safe() game = get_latest_game_safe("game")
if not game: if not game:
# игра ещё не подгружена или структура кривоватая # игра ещё не подгружена или структура кривоватая
raise HTTPException(status_code=503, detail="game data not ready") raise HTTPException(status_code=503, detail="game data not ready")
full_stat = get_latest_game_safe("pregame-full-stats")
if not full_stat:
raise HTTPException(status_code=503, detail="pregame-full-stats data not ready")
# нормализуем доступ к данным # нормализуем доступ к данным
game_data = game["data"] if "data" in game else game game_data = game["data"] if "data" in game else game
full_stat_data = full_stat["data"] if "data" in full_stat else full_stat
result = game_data[ result = game_data[
"result" "result"
] # здесь уже безопасно, мы проверили в get_latest_game_safe ] # здесь уже безопасно, мы проверили в get_latest_game_safe
result_full = full_stat_data["result"]
# в result ожидаем "teams" # в result ожидаем "teams"
teams = result.get("teams") teams = result.get("teams")
if not teams: if not teams:
raise HTTPException(status_code=503, detail="game teams not ready") raise HTTPException(status_code=503, detail="game teams not ready")
# выбираем команду # выбираем команду
if who == "team1": if who == "team1":
payload = next((t for t in teams if t.get("teamNumber") == 1), None) payload = next((t for t in teams if t.get("teamNumber") == 1), None)
payload_full = result_full.get("team1PlayersStats")
else: else:
payload = next((t for t in teams if t.get("teamNumber") == 2), None) payload = next((t for t in teams if t.get("teamNumber") == 2), None)
payload_full = result_full.get("team2PlayersStats")
if payload is None: if payload is None:
raise HTTPException(status_code=404, detail=f"{who} not found in game data") raise HTTPException(status_code=404, detail=f"{who} not found in game data")
@@ -1383,10 +1477,126 @@ async def team(who: str):
("Forward-Center", "FC"), ("Forward-Center", "FC"),
] ]
starts = payload.get("starts", []) starts = payload.get("starts", [])
team_rows = [] team_rows = []
for item in starts: for item in starts:
stats = item.get("stats") or {} stats = item.get("stats") or {}
pid = str(item.get("personId"))
full_obj = next((p for p in (payload_full or []) if str(p.get("personId")) == pid), None)
season_sum = season_avg = career_sum = career_avg = {}
if full_obj:
# сезон
season_sum, season_avg = _pick_last_avg_and_sum(full_obj.get("seasonStats") or [])
# карьера
career_sum, career_avg = _pick_career_sum_and_avg(full_obj.get("carrier") or [])
season_sum = _safe(season_sum)
season_avg = _safe(season_avg)
career_sum = _safe(career_sum)
career_avg = _safe(career_avg)
# Полезные числа для Totals+Live
# live-поля в box-score называются goal1/2/3, shot1/2/3, defReb/offReb и т.п.
g1 = _as_int(stats.get("goal1"))
s1 = _as_int(stats.get("shot1"))
g2 = _as_int(stats.get("goal2"))
s2 = _as_int(stats.get("shot2"))
g3 = _as_int(stats.get("goal3"))
s3 = _as_int(stats.get("shot3"))
# Сезонные суммы из pregame-full-stats
ss_pts = _as_int(season_sum.get("points"))
ss_ast = _as_int(season_sum.get("assist"))
ss_blk = _as_int(season_sum.get("blockShot"))
ss_dreb = _as_int(season_sum.get("defRebound"))
ss_oreb = _as_int(season_sum.get("offRebound"))
ss_reb = _as_int(season_sum.get("rebound"))
ss_stl = _as_int(season_sum.get("steal"))
ss_to = _as_int(season_sum.get("turnover"))
ss_foul = _as_int(season_sum.get("foul"))
ss_sec = _as_int(season_sum.get("second"))
ss_gms = _as_int(season_sum.get("games"))
ss_st = _as_int(season_sum.get("isStarts"))
ss_g1 = _as_int(season_sum.get("goal1"))
ss_s1 = _as_int(season_sum.get("shot1"))
ss_g2 = _as_int(season_sum.get("goal2"))
ss_s2 = _as_int(season_sum.get("shot2"))
ss_g3 = _as_int(season_sum.get("goal3"))
ss_s3 = _as_int(season_sum.get("shot3"))
# Карьерные суммы из pregame-full-stats
car_ss_pts = _as_int(career_sum.get("points"))
car_ss_ast = _as_int(career_sum.get("assist"))
car_ss_blk = _as_int(career_sum.get("blockShot"))
car_ss_dreb = _as_int(career_sum.get("defRebound"))
car_ss_oreb = _as_int(career_sum.get("offRebound"))
car_ss_reb = _as_int(career_sum.get("rebound"))
car_ss_stl = _as_int(career_sum.get("steal"))
car_ss_to = _as_int(career_sum.get("turnover"))
car_ss_foul = _as_int(career_sum.get("foul"))
car_ss_sec = _as_int(career_sum.get("second"))
car_ss_gms = _as_int(career_sum.get("games"))
car_ss_st = _as_int(career_sum.get("isStarts"))
car_ss_g1 = _as_int(career_sum.get("goal1"))
car_ss_s1 = _as_int(career_sum.get("shot1"))
car_ss_g2 = _as_int(career_sum.get("goal2"))
car_ss_s2 = _as_int(career_sum.get("shot2"))
car_ss_g3 = _as_int(career_sum.get("goal3"))
car_ss_s3 = _as_int(career_sum.get("shot3"))
# Totals по сезону, «с учётом текущего матча»:
T_points = ss_pts + _as_int(stats.get("points"))
T_assist = ss_ast + _as_int(stats.get("assist"))
T_block = ss_blk + _as_int(stats.get("block"))
T_dreb = ss_dreb + _as_int(stats.get("defReb"))
T_oreb = ss_oreb + _as_int(stats.get("offReb"))
T_reb = ss_reb + (_as_int(stats.get("defReb")) + _as_int(stats.get("offReb")))
T_steal = ss_stl + _as_int(stats.get("steal"))
T_turn = ss_to + _as_int(stats.get("turnover"))
T_foul = ss_foul + _as_int(stats.get("foul"))
T_sec = ss_sec + _as_int(stats.get("second"))
T_gms = ss_gms + (1 if _as_int(stats.get("second")) > 0 else 0)
T_starts = ss_st + (1 if bool(stats.get("isStart")) else 0)
T_g1 = ss_g1 + g1
T_s1 = ss_s1 + s1
T_g2 = ss_g2 + g2
T_s2 = ss_s2 + s2
T_g3 = ss_g3 + g3
T_s3 = ss_s3 + s3
# Totals по карьере, «с учётом текущего матча»:
car_T_points = car_ss_pts + _as_int(stats.get("points"))
car_T_assist = car_ss_ast + _as_int(stats.get("assist"))
car_T_block = car_ss_blk + _as_int(stats.get("block"))
car_T_dreb = car_ss_dreb + _as_int(stats.get("defReb"))
car_T_oreb = car_ss_oreb + _as_int(stats.get("offReb"))
car_T_reb = car_ss_reb + (_as_int(stats.get("defReb")) + _as_int(stats.get("offReb")))
car_T_steal = car_ss_stl + _as_int(stats.get("steal"))
car_T_turn = car_ss_to + _as_int(stats.get("turnover"))
car_T_foul = car_ss_foul + _as_int(stats.get("foul"))
car_T_sec = car_ss_sec + _as_int(stats.get("second"))
car_T_gms = car_ss_gms + (1 if _as_int(stats.get("second")) > 0 else 0)
car_T_starts = car_ss_st + (1 if bool(stats.get("isStart")) else 0)
car_T_g1 = car_ss_g1 + g1
car_T_s1 = car_ss_s1 + s1
car_T_g2 = car_ss_g2 + g2
car_T_s2 = car_ss_s2 + s2
car_T_g3 = car_ss_g3 + g3
car_T_s3 = car_ss_s3 + s3
# Проценты (без деления на 0)
def _pct(goal, shot):
return f"{round(goal*100/shot, 1)}%" if shot else "0.0%"
# Для «23» используем сумму 2-х и 3-х
T_g23 = T_g2 + T_g3
T_s23 = T_s2 + T_s3
car_T_g23 = car_T_g2 + car_T_g3
car_T_s23 = car_T_s2 + car_T_s3
# print(avg_season, total_season)
row = { row = {
"id": item.get("personId") or "", "id": item.get("personId") or "",
"num": item.get("displayNumber"), "num": item.get("displayNumber"),
@@ -1434,69 +1644,121 @@ async def team(who: str):
) )
+ ".svg" + ".svg"
), ),
"pts": stats.get("points", 0), # live-стата
"pt-2": f"{stats.get('goal2',0)}/{stats.get('shot2',0)}" if stats else 0, "pts": _as_int(stats.get("points")),
"pt-3": f"{stats.get('goal3',0)}/{stats.get('shot3',0)}" if stats else 0, "pt-2": f"{g2}/{s2}",
"pt-1": f"{stats.get('goal1',0)}/{stats.get('shot1',0)}" if stats else 0, "pt-3": f"{g3}/{s3}",
"fg": ( "pt-1": f"{g1}/{s1}",
f"{stats.get('goal2',0)+stats.get('goal3',0)}/" "fg": f"{g2+g3}/{s2+s3}",
f"{stats.get('shot2',0)+stats.get('shot3',0)}" "ast": _as_int(stats.get("assist")),
if stats "stl": _as_int(stats.get("steal")),
else 0 "blk": _as_int(stats.get("block")),
), "blkVic": _as_int(stats.get("blocked")),
"ast": stats.get("assist", 0), "dreb": _as_int(stats.get("defReb")),
"stl": stats.get("steal", 0), "oreb": _as_int(stats.get("offReb")),
"blk": stats.get("block", 0), "reb": _as_int(stats.get("defReb")) + _as_int(stats.get("offReb")),
"blkVic": stats.get("blocked", 0), "to": _as_int(stats.get("turnover")),
"dreb": stats.get("defReb", 0), "foul": _as_int(stats.get("foul")),
"oreb": stats.get("offReb", 0), "foulT": _as_int(stats.get("foulT")),
"reb": stats.get("defReb", 0) + stats.get("offReb", 0), "foulD": _as_int(stats.get("foulD")),
"to": stats.get("turnover", 0), "foulC": _as_int(stats.get("foulC")),
"foul": stats.get("foul", 0), "foulB": _as_int(stats.get("foulB")),
"foulT": stats.get("foulT", 0), "fouled": _as_int(stats.get("foulsOn")),
"foulD": stats.get("foulD", 0), "plusMinus": _as_int(stats.get("plusMinus")),
"foulC": stats.get("foulC", 0), "dunk": _as_int(stats.get("dunk")),
"foulB": stats.get("foulB", 0), "kpi": (
"fouled": stats.get("foulsOn", 0), _as_int(stats.get("points"))
"plusMinus": stats.get("plusMinus", 0), + _as_int(stats.get("defReb")) + _as_int(stats.get("offReb"))
"dunk": stats.get("dunk", 0), + _as_int(stats.get("assist")) + _as_int(stats.get("steal")) + _as_int(stats.get("block"))
"kpi": ( + _as_int(stats.get("foulsOn"))
stats.get("points", 0) + (g1 - s1) + (g2 - s2) + (g3 - s3)
+ stats.get("defReb", 0) - _as_int(stats.get("turnover")) - _as_int(stats.get("foul"))
+ stats.get("offReb", 0) ),
+ stats.get("assist", 0) "time": format_time(_as_int(stats.get("second"))),
+ stats.get("steal", 0)
+ stats.get("block", 0)
+ stats.get("foulsOn", 0)
+ (stats.get("goal1", 0) - stats.get("shot1", 0))
+ (stats.get("goal2", 0) - stats.get("shot2", 0))
+ (stats.get("goal3", 0) - stats.get("shot3", 0))
- stats.get("turnover", 0)
- stats.get("foul", 0)
),
"time": format_time(stats.get("second", 0)),
"pts1q": 0,
"pts2q": 0,
"pts3q": 0,
"pts4q": 0,
"pts1h": 0,
"pts2h": 0,
"Name1GFX": (item.get("firstName") or "").strip(),
"Name2GFX": (item.get("lastName") or "").strip(),
"photoGFX": (
os.path.join(
"D:\\Photos",
LEAGUE.lower(),
result[who]["name"],
f"{item.get('displayNumber')}.png",
)
if item.get("startRole") == "Player"
else ""
),
"isOnCourt": stats.get("isOnCourt", False),
}
team_rows.append(row)
# сезон — средние (из последнего Avg)
"AvgPoints": season_avg.get("points") or "0.0",
"AvgAssist": season_avg.get("assist") or "0.0",
"AvgBlocks": season_avg.get("blockShot") or "0.0",
"AvgDefRebound": season_avg.get("defRebound") or "0.0",
"AvgOffRebound": season_avg.get("offRebound") or "0.0",
"AvgRebound": season_avg.get("rebound") or "0.0",
"AvgSteal": season_avg.get("steal") or "0.0",
"AvgTurnover": season_avg.get("turnover") or "0.0",
"AvgFoul": season_avg.get("foul") or "0.0",
"AvgOpponentFoul": season_avg.get("foulsOnPlayer") or "0.0",
"AvgDunk": season_avg.get("dunk") or "0.0",
"AvgPlayedTime": season_avg.get("playedTime") or "0:00",
"Shot1Percent": season_avg.get("shot1Percent") or "0.0%",
"Shot2Percent": season_avg.get("shot2Percent") or "0.0%",
"Shot3Percent": season_avg.get("shot3Percent") or "0.0%",
"Shot23Percent": season_avg.get("shot23Percent") or "0.0%",
# сезон — Totals (суммы из Sum + live)
"TPoints": T_points,
"TShots1": f"{T_g1}/{T_s1}",
"TShots2": f"{T_g2}/{T_s2}",
"TShots3": f"{T_g3}/{T_s3}",
"TShots23": f"{T_g23}/{T_s23}",
"TShot1Percent": _pct(T_g1, T_s1),
"TShot2Percent": _pct(T_g2, T_s2),
"TShot3Percent": _pct(T_g3, T_s3),
"TShot23Percent": _pct(T_g23, T_s23),
"TAssist": T_assist,
"TBlocks": T_block,
"TDefRebound": T_dreb,
"TOffRebound": T_oreb,
"TRebound": T_reb,
"TSteal": T_steal,
"TTurnover": T_turn,
"TFoul": T_foul,
"TPlayedTime": format_time(T_sec),
"TGameCount": T_gms,
"TStartCount": T_starts,
# карьера — средние (из последнего Avg)
"Career_AvgPoints": career_avg.get("points") or "0.0",
"Career_AvgAssist": career_avg.get("assist") or "0.0",
"Career_AvgBlocks": career_avg.get("blockShot") or "0.0",
"Career_AvgDefRebound": career_avg.get("defRebound") or "0.0",
"Career_AvgOffRebound": career_avg.get("offRebound") or "0.0",
"Career_AvgRebound": career_avg.get("rebound") or "0.0",
"Career_AvgSteal": career_avg.get("steal") or "0.0",
"Career_AvgTurnover": career_avg.get("turnover") or "0.0",
"Career_AvgFoul": career_avg.get("foul") or "0.0",
"Career_AvgOpponentFoul": career_avg.get("foulsOnPlayer") or "0.0",
"Career_AvgDunk": career_avg.get("dunk") or "0.0",
"Career_AvgPlayedTime": career_avg.get("playedTime") or "0:00",
"Career_Shot1Percent": career_avg.get("shot1Percent") or "0.0%",
"Career_Shot2Percent": career_avg.get("shot2Percent") or "0.0%",
"Career_Shot3Percent": career_avg.get("shot3Percent") or "0.0%",
"Career_Shot23Percent": career_avg.get("shot23Percent") or "0.0%",
# карьера — Totals (суммы из Sum + live)
"Career_TPoints": car_T_points,
"Career_TShots1": f"{car_T_g1}/{car_T_s1}",
"Career_TShots2": f"{car_T_g2}/{car_T_s2}",
"Career_TShots3": f"{car_T_g3}/{car_T_s3}",
"Career_TShots23": f"{car_T_g23}/{car_T_s23}",
"Career_TShot1Percent": _pct(car_T_g1, car_T_s1),
"Career_TShot2Percent": _pct(car_T_g2, car_T_s2),
"Career_TShot3Percent": _pct(car_T_g3, car_T_s3),
"Career_TShot23Percent": _pct(car_T_g23, car_T_s23),
"Career_TAssist": car_T_assist,
"Career_TBlocks": car_T_block,
"Career_TDefRebound": car_T_dreb,
"Career_TOffRebound": car_T_oreb,
"Career_TRebound": car_T_reb,
"Career_TSteal": car_T_steal,
"Career_TTurnover": car_T_turn,
"Career_TFoul": car_T_foul,
"Career_TPlayedTime": format_time(car_T_sec),
"Career_TGameCount": car_T_gms,
"Career_TStartCount": car_T_starts,
}
team_rows.append(row)
# добиваем до 12 строк, чтобы UI был ровный # добиваем до 12 строк, чтобы UI был ровный
count_player = sum(1 for x in team_rows if x["startRole"] == "Player") count_player = sum(1 for x in team_rows if x["startRole"] == "Player")
if count_player < 12 and team_rows: if count_player < 12 and team_rows: