From bfa74b51c3df6cbc59caf3311dc2ffe611901b50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=AE=D1=80=D0=B8=D0=B9=20=D0=A7=D0=B5=D1=80=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=BA=D0=BE?= Date: Thu, 27 Nov 2025 18:02:06 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D1=82=D0=BE=D0=BF=20=D0=B8=20=D0=BF=D0=BE=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D0=BB=20=D1=81=D0=BB=D0=B5=D0=B4=D1=83=D1=8E=D1=89?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BC=D0=B0=D1=82=D1=87=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- get_data.py | 1372 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 1360 insertions(+), 12 deletions(-) diff --git a/get_data.py b/get_data.py index a315369..4815a08 100644 --- a/get_data.py +++ b/get_data.py @@ -1,5 +1,5 @@ from fastapi import FastAPI, HTTPException, Request -from fastapi.responses import Response, HTMLResponse, StreamingResponse +from fastapi.responses import Response, HTMLResponse, StreamingResponse, JSONResponse from fastapi.staticfiles import StaticFiles from contextlib import asynccontextmanager import requests, uvicorn, json @@ -202,6 +202,8 @@ URLS = { "live-status": "{host}api/abc/games/live-status?id={game_id}", "box-score": "{host}api/abc/games/box-score?id={game_id}", "play-by-play": "{host}api/abc/games/play-by-play?id={game_id}", + "players-stats-league": "{host}/api/abc/comps/players-stats?tag={league}&lang={lang}&maxResultCount=10000", + "players-stats-season": "{host}/api/abc/comps/players-stats?tag={league}&season={season}&lang={lang}&maxResultCount=10000", } @@ -1398,6 +1400,37 @@ async def lifespan(app: FastAPI): ) thread_excel.start() + try: + season_stats = requests.get( + URLS["players-stats-season"].format( + host=HOST, league=LEAGUE, season=season, lang=LANG + ) + ).json() + except Exception as ex: + season_stats = None + try: + league_stats = requests.get( + URLS["players-stats-league"].format(host=HOST, league=LEAGUE, lang=LANG) + ).json() + except Exception as ex: + league_stats = None + + # 👇 ДОБАВЬ ЭТО + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + + if season_stats is not None: + latest_data["season_stats"] = { + "ts": ts, + "data": season_stats, + } + + if league_stats is not None: + latest_data["league_stats"] = { + "ts": ts, + "data": league_stats, + } + # 👆 ДО СЮДА + # 4. запускаем "длинные" потоки (они у тебя и так всегда) thread_result_consumer = threading.Thread( target=results_consumer, @@ -1473,16 +1506,6 @@ app = FastAPI( redoc_url=None, # ❌ отключает /redoc openapi_url=None, # ❌ отключает /openapi.json ) -# раздаём /shotmaps как статику из SHOTMAP_DIR -# app.mount("/shotmaps", StaticFiles(directory=SHOTMAP_DIR), name="shotmaps") - -# @app.get("/shotmaps/{filename}") -# async def get_shotmap(filename: str): -# data = SHOTMAP_CACHE.get(filename) -# if not data: -# # если вдруг перезапустился процесс или такой карты нет -# raise HTTPException(status_code=404, detail="Shotmap not found") -# return Response(content=data, media_type="image/png") def format_time(seconds: float | int) -> str: @@ -2246,7 +2269,12 @@ async def team(who: str): f"{item.get('displayNumber')}.png", ) if item.get("startRole") == "Player" - else "" + else os.path.join( + "D:\\Photos", + result["league"]["abcName"], + result[who]["name"], + f'Head Coach_{(item.get("lastName") or "").strip()} {(item.get("firstName") or "").strip()}.png', + ) ), # live-стата "pts": _as_int(stats.get("points")), @@ -3741,6 +3769,30 @@ async def last_5_games(): if not CALENDAR or "items" not in CALENDAR: return {"opponent": "", "date": "", "place": "", "place_ru": ""} + WEEKDAYS_EN = [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", + ] + MONTHS_EN = [ + "january", + "february", + "march", + "april", + "may", + "june", + "july", + "august", + "september", + "october", + "november", + "december", + ] + now = datetime.now() # наивное "сейчас" best = None # {"dt": ..., "opp": ..., "place": "home"/"away"} @@ -3782,11 +3834,23 @@ async def last_5_games(): place_ru = "дома" if best["place"] == "home" else "в гостях" + # 🆕 формируем английскую строку + dt = best["dt"] + weekday_en = WEEKDAYS_EN[dt.weekday()] # monday..sunday + month_en = MONTHS_EN[dt.month - 1] # january..december + day = dt.day # 1..31 + place_en = "home" if best["place"] == "home" else "away" + + formatted = ( + f"{weekday_en}, {month_en} {day}, at {place_en} against {best['opp']}" + ) + return { "opponent": best["opp"], "date": best["dt"].strftime("%Y-%m-%d %H:%M"), "place": best["place"], # "home" / "away" "place_ru": place_ru, # "дома" / "в гостях" + "formatted": formatted, # 🆕 "wednesday, march 26, at home against astana" } # последние 5 игр и результаты @@ -3809,6 +3873,7 @@ async def last_5_games(): "nextGameDate": next1["date"], "nextGamePlace": next1["place_ru"], # "дома" / "в гостях" "nextGameHomeAway": next1["place"], # "home" / "away" (если нужно в логике) + "nextGameFormatted": next1["formatted"], }, { "teamName": team2_name, @@ -3818,6 +3883,7 @@ async def last_5_games(): "nextGameDate": next2["date"], "nextGamePlace": next2["place_ru"], "nextGameHomeAway": next2["place"], + "nextGameFormatted": next2["formatted"], }, ] return data @@ -4906,6 +4972,1288 @@ async def dashboard(): return HTMLResponse(content=html) +@app.get("/game_history") +async def game_history(): + pregame = get_latest_game_safe("pregame") + if not pregame: + return [{"Данных об истории команд нет!"}] + + pregame_data = pregame["data"] if "data" in pregame else pregame + result = pregame_data.get("result", {}).get("gameHistory", {}) or {} + history = [] + + for row in result: + row_team1 = get_excel_row_for_team(row["team1"]["name"]) or {} + row_team2 = get_excel_row_for_team(row["team2"]["name"]) or {} + history.append( + { + "team1": row_team1.get("TeamTLA", ""), + "team2": row_team2.get("TeamTLA", ""), + "team1_logo": row_team1.get("TeamLogo", ""), + "team2_logo": row_team2.get("TeamLogo", ""), + "score1": row["game"]["score1"], + "score2": row["game"]["score2"], + "localDate": row["game"]["localDate"], + "team1Win": pregame_data.get("result", {}).get("team1Win"), + "team2Win": pregame_data.get("result", {}).get("team2Win"), + } + ) + return history + + +# Глобальное состояние выбранной сортировки +CURRENT_SORT = { + "sort_by": None, + "direction": "desc", +} + + +def get_nested_value(data, path: str, default=0): + if not path: + return default + + keys = path.split(".") + current = data + + for key in keys: + if isinstance(current, dict): + if key not in current: + return default + current = current[key] + elif isinstance(current, list): + try: + idx = int(key) + if idx < 0 or idx >= len(current): + return default + current = current[idx] + except: + return default + else: + return default + + return current if current not in (None, "", []) else default + + +def collect_paths(prefix, obj, out: set): + """ + Рекурсивно собирает все пути ключей: + lastName + season.points + carrier.points + season.0.points + """ + if isinstance(obj, dict): + for k, v in obj.items(): + new_prefix = f"{prefix}.{k}" if prefix else k + out.add(new_prefix) + collect_paths(new_prefix, v, out) + elif isinstance(obj, list): + for i, v in enumerate(obj): + new_prefix = f"{prefix}.{i}" if prefix else str(i) + out.add(new_prefix) + collect_paths(new_prefix, v, out) + + +def extract_all_sort_paths(items: list[dict]) -> list[str]: + """ + Собирает уникальные пути для сортировки из всех элементов milestones, + исключая нежелательные ключи. + """ + paths: set[str] = set() + for item in items: + collect_paths("", item, paths) + + # ключи, которые нужно исключить + EXCLUDE = { + "fastBreak", + "isStart", + "foulB", + "foulC", + "foulD", + "foulT", + "isDoubleDouble", + "isTripleDouble", + "pass", + "second", + "blockedOwnShot", + "playedTime", + } + + filtered = [] + for p in paths: + last_key = p.split(".")[-1] + if last_key not in EXCLUDE: + filtered.append(p) + + return sorted(filtered) + +def _build_milestones() -> list[dict]: + """ + Собирает milestones по всем игрокам обеих команд из pregame-full-stats. + Возвращает список словарей с полями: + lastName, firstName, team, season, career + где season / career — это dict со статистикой (обычно Sum). + """ + players_stats_league = (latest_data.get("league_stats") or {}).get("data", {}).get( + "items" + ) or [] + if not players_stats_league: + return [] + players_stats_season = (latest_data.get("season_stats") or {}).get("data", {}).get( + "items" + ) or [] + if not players_stats_season: + return [] + + milestones: list[dict] = [] + excel_wrap = latest_data.get("excel_TEAMS_LEGEND").get("data") or [] + + for player in players_stats_league: + # все записи по этому игроку (обычно одна) + season_items = [ + item + for item in players_stats_season + if item.get("personId") == player.get("personId") + ] + team = "" + display_number = 0 + team_id = None + + if season_items: + season_item = season_items[0] + base_season_stats = season_item.get("stats") or {} + season_games = season_item.get("games", 0) + + season_stats = dict(base_season_stats) + season_stats["games"] = season_games + + team_id = season_item.get("team", {}).get("id") + display_number = season_item.get("displayNumber", 0) + else: + season_stats = {} + + # 🔍 Ищем название команды в excel_wrap + team_name = "" + if team_id: + for row in excel_wrap: + if int(row.get("Id")) == int(team_id): + team_name = row.get("Team", "") + break + + # карьера из league_stats + base_career_stats = player.get("stats") or {} + career_games = player.get("games", 0) + career_stats = dict(base_career_stats) + career_stats["games"] = career_games + + season_sum = season_stats # dict + career_sum = career_stats # dict + season_avg = compute_avg(season_sum) + career_avg = compute_avg(career_sum) + + milestones.append( + { + "lastName": (player.get("person") or {}).get("lastName", ""), + "firstName": (player.get("person") or {}).get("firstName", ""), + "team": ( + team_name if team_name != "" else player.get("team").get("name") + ), + "displayNumber": display_number, + "season": season_stats, + "career": career_stats, + "season_avg": season_avg, + "career_avg": career_avg, + } + ) + return milestones + + +def format_float(value): + """ + Возвращает строку float всегда с дробной частью: + 12 -> "12.0" + 12.0 -> "12.0" + 12.5 -> "12.5" + None -> "0.0" + """ + # пустые значения -> 0.0 + if value in (None, "", [], {}): + return "0.0" + + # если уже float или int + if isinstance(value, (int, float)): + val = float(value) + # всегда форматируем с дробной частью + return f"{val:.1f}" + + # если строка + if isinstance(value, str): + try: + val = float(value.replace(",", ".")) + return f"{val:.1f}" + except: + return "0.0" + + # fallback + try: + val = float(value) + return f"{val:.1f}" + except: + return "0.0" + + +def compress_milestone_for_vmix(m: dict) -> dict: + """ + Лёгкая версия игрока для vMix: + только имя, фамилия, команда, выбранная статистика, её значение и игры. + """ + data = latest_data["game"]["data"]["result"] + season_api = ( + f'{str(data["league"]["season"]-1)}-{str(data["league"]["season"])[2:]}' + ) + + return { + "firstName": m.get("firstName", ""), + "lastName": m.get("lastName", ""), + "displayNumber": m.get("displayNumber", ""), + "photo": m.get("photo", ""), + "team": m.get("team", ""), + "logo_xls": m.get("logo_xls", ""), + "statName1": ( + f"Regular season {season_api}" + if "season" in m.get("statName", "").split(".")[0] + else "VTB History" + ), + "statName2": ( + f'{m.get("statName", "").split(".")[1].replace("points", "point")}s' + if "shot" not in m.get("statName", "").split(".")[1] + else m.get("statName", "").split(".")[1] + ), + "statValue": m.get("statValue", ""), + "games": m.get("games", ""), + } + + +@app.get("/milestones") +async def milestones( + sort_by: str | None = None, + direction: str | None = None, + vmix: bool = False, # 👈 добавили флаг +): + global CURRENT_SORT + + if sort_by is not None: + CURRENT_SORT["sort_by"] = sort_by + + if direction is not None: + CURRENT_SORT["direction"] = direction + + sort_by = CURRENT_SORT.get("sort_by") + direction = CURRENT_SORT.get("direction") or "desc" + milestones = _build_milestones() + if not milestones: + return [{"message": "Данных об истории команд нет!"}] + + if sort_by: + reverse = direction != "asc" + + def sort_key(x): + raw = get_nested_value(x, sort_by, 0) + return normalize_number(raw) + + milestones = sorted(milestones, key=sort_key, reverse=reverse) + + for m in milestones: + row_team = get_excel_row_for_team(m["team"]) + team_logo_exl = row_team.get("TeamLogo", "") + m["statName"] = sort_by + + # значение для сортировки + raw_val = sort_key(m) + + # 🔹 Если сортировка по AVG — оставляем float с дробной частью + if sort_by.startswith("season_avg.") or sort_by.startswith("career_avg."): + m["statValue"] = format_float(raw_val) + else: + # 🔹 Не AVG — всегда целые числа, если это возможно + val = normalize_number(raw_val) + if isinstance(val, float) and val.is_integer(): + val = int(val) + m["statValue"] = val + + # 🆕 GAMES: + games_val = 0 + + if sort_by.startswith("season_avg."): + # сначала пробуем games из season_avg, если там нет — из season (Sum) + games_raw = get_nested_value(m, "season_avg.games", None) + if games_raw is None: + games_raw = get_nested_value(m, "season.games", 0) + games_val = normalize_number(games_raw) + + elif sort_by.startswith("season."): + # обычный сезонный Sum + games_raw = get_nested_value(m, "season.games", 0) + games_val = normalize_number(games_raw) + + elif sort_by.startswith("career_avg."): + # сначала career_avg.games, если пусто — career.games + games_raw = get_nested_value(m, "career_avg.games", None) + if games_raw is None: + games_raw = get_nested_value(m, "career.games", 0) + games_val = normalize_number(games_raw) + + elif sort_by.startswith("carrier.") or sort_by.startswith("career."): + # старый путь carrier/career (Sum) + games_raw = get_nested_value( + m, "carrier.games", None + ) or get_nested_value(m, "career.games", 0) + games_val = normalize_number(games_raw) + + # на всякий случай тоже дожмём до int, если вдруг float + if isinstance(games_val, float) and games_val.is_integer(): + games_val = int(games_val) + + m["games"] = games_val + m["logo_xls"] = team_logo_exl + m["photo"] = os.path.join( + "D:\\Photos", + "vtb", + m["team"], + f"{m.get('displayNumber')}.png", + ) + + else: + for m in milestones: + m["statName"] = None + m["statValue"] = None + m["games"] = None + m["logo_xls"] = None + m["photo"] = r"D:\Графика\БАСКЕТБОЛ\VTB League\ASSETS\EMPTY.png" + if vmix: + return [compress_milestone_for_vmix(m) for m in milestones[:20]] + + return milestones + + +def compute_avg(stats: dict) -> dict: + """ + Принимает суммарную статистику игрока вида: + { "points": 120, "rebounds": 50, "games": 6, ... } + + Возвращает словарь со средними значениями: + { "points": 20.0, "rebounds": 8.3, ... } + + Формат всегда float с .0 + """ + if not isinstance(stats, dict): + return {} + + games = stats.get("games", 0) + if not isinstance(games, (int, float)) or games == 0: + return {} + + avg = {} + for key, value in stats.items(): + if key == "games": + continue + if not isinstance(value, (int, float)): + continue + + avg_val = value / games + + # всегда форматируем как float с .0 + if isinstance(avg_val, float) and avg_val.is_integer(): + avg_val = float(f"{int(avg_val)}.0") + else: + avg_val = float(f"{avg_val:.1f}") + + avg[key] = avg_val + + return avg + + +def normalize_number(value): + """ + Превращает значение в корректное число: + - '12' -> 12 + - '12.0' -> 12 + - '12.5' -> 12.5 + - None/'' -> 0 + """ + if value in (None, "", [], {}): + return 0 + + # если уже int + if isinstance(value, int): + return value + + # если float + if isinstance(value, float): + # если выглядит как целое — сделать int + return int(value) if value.is_integer() else value + + # если строка + if isinstance(value, str): + try: + v = float(value.replace(",", ".")) + return int(v) if v.is_integer() else v + except: + return 0 + + # если что-то другое — неиспользуемый тип + return 0 + + +@app.get("/milestones/ui", response_class=HTMLResponse) +async def milestones_ui(): + milestones = _build_milestones() + keys = extract_all_sort_paths(milestones) + keys_js = json.dumps(keys, ensure_ascii=False) + + html = """ + + + + + Milestones + + + +
+
+
+
+
+ Milestones игроков + live +
+
+ Слева — список игроков, справа — подробная статистика по 4 блокам (Season / Season Avg / Career / Career Avg).
+ Сортировку меняем выбором блока и показателя. +
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ Выбери блок + показатель, чтобы отсортировать +
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + +
+ Игрок + + + Команда + + + Статистика + + Значение + + Games + + Сезон (очки) + + + Карьера (очки) + + + Сырые данные +
+
+ + +
+ + +
+
+
+ + + + + """ + + html = html.replace("PLACEHOLDER_KEYS", keys_js) + return HTMLResponse(content=html) + + if __name__ == "__main__": uvicorn.run( "get_data:app", host="0.0.0.0", port=8000, reload=True, log_level="debug"