From 09dfbef09a5892abd8ceb5f52508e7b07fa26f52 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: Mon, 27 Oct 2025 17:34:50 +0300 Subject: [PATCH] test2 --- get_data_new.py | 1141 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 1082 insertions(+), 59 deletions(-) diff --git a/get_data_new.py b/get_data_new.py index 896324b..be68a35 100644 --- a/get_data_new.py +++ b/get_data_new.py @@ -1,5 +1,5 @@ import time -from datetime import datetime +from datetime import datetime, timedelta, timezone from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry import requests @@ -12,8 +12,10 @@ import sys import logging import pandas as pd import logging.config +from typing import Any, Dict, List from zoneinfo import ZoneInfo import threading +from pathlib import Path from concurrent.futures import ThreadPoolExecutor, as_completed HOST = "https://ref.russiabasket.org" @@ -359,7 +361,58 @@ def get_interval_by_name(name: str) -> int: raise ValueError(f"interval not found for {name}") -def poll_game_live(session, league: str, season: str, game_id: int, lang: str, game_meta: dict): +def run_live_loop( + parent_session_unused, + league: str, + season: str, + game_id: int, + lang: str, + game_meta: dict, +): + """ + Поток, который дергает онлайн API матча. + По завершении матча говорит рендеру остановиться. + """ + logger.info( + f"[LIVE_THREAD] start live loop for game_id={game_id} (league={league}, season={season})" + ) + + # создаём свою сессию, чтобы не делить session между потоками + session = create_session() + + # общий stop_event для live и render + stop_event = threading.Event() + + # запускаем рендер-поток + render_thread = threading.Thread( + target=render_loop, + args=(stop_event,), # только stop_event, out_name используем дефолт "ui_state" + daemon=True, + ) + render_thread.start() + logger.info("[LIVE_THREAD] render thread spawned") + + try: + # крутим опрос API до конца матча + poll_game_live( + session=session, + league=league, + season=season, + game_id=game_id, + lang=lang, + game_meta=game_meta, + ) + except Exception as e: + logger.exception(f"[LIVE_THREAD] crash in live loop for game_id={game_id}: {e}") + finally: + # матч кончился -> выключаем рендер + stop_event.set() + logger.info(f"[LIVE_THREAD] stop live loop for game_id={game_id}") + + +def poll_game_live( + session, league: str, season: str, game_id: int, lang: str, game_meta: dict +): """ Онлайн-цикл: - "game" и "pregame-fullstats" раз в 600 сек @@ -369,7 +422,9 @@ def poll_game_live(session, league: str, season: str, game_id: int, lang: str, g Цикл выходит, когда матч перестаёт быть live. """ - slow_endpoints = ["game",] #"pregame-fullstats"] + slow_endpoints = [ + "game", + ] # "pregame-fullstats"] fast_endpoints = ["live-status", "box-score", "play-by-play"] last_call = {} @@ -415,9 +470,7 @@ def poll_game_live(session, league: str, season: str, game_id: int, lang: str, g # проверяем статус, не закончилась ли игра if isinstance(data, dict): st = ( - data.get("status") - or data.get("gameStatus") - or "" + data.get("status") or data.get("gameStatus") or "" ).lower() if st in ("resultconfirmed", "finished", "final"): logger.info( @@ -430,7 +483,9 @@ def poll_game_live(session, league: str, season: str, game_id: int, lang: str, g # вторая страховка (инфо из календаря) if not is_game_live(game_meta): - logger.info(f"Game {game_id} no longer live by calendar meta -> stop loop") + logger.info( + f"Game {game_id} no longer live by calendar meta -> stop loop" + ) break if game_finished: @@ -438,45 +493,7 @@ def poll_game_live(session, league: str, season: str, game_id: int, lang: str, g # чуть притормозим, чтобы не жарить CPU time.sleep(0.2) - logger.debug("live poll tick ok") - - -def get_data_API2(session, league: str, team: str, lang: str): - json_seasons = fetch_api_data( - session, "seasons", host=HOST, league=league, lang=lang - ) - if not json_seasons: - print("Не удалось получить список сезонов") - return - - season = json_seasons[0]["season"] - fetch_api_data( - session, "standings", host=HOST, league=league, season=season, lang=lang - ) - json_calendar = fetch_api_data( - session, "calendar", host=HOST, league=league, season=season, lang=lang - ) - if not json_calendar: - print("Не удалось получить список матчей") - return - - today_game, last_played = get_game_id(json_calendar, team) - if last_played: - game_id = last_played["game"]["id"] - fetch_api_data(session, "game", host=HOST, game_id=game_id, lang=lang) - elif today_game: - print("ОНЛАЙН") - game_id = today_game["game"]["id"] - fetch_api_data(session, "game", host=HOST, game_id=game_id, lang=lang) - fetch_api_data( - session, - "pregame", - host=HOST, - league=league, - season=season, - game_id=game_id, - lang=lang, - ) + logger.debug("live poll tick ok") def get_data_API(session, league: str, team: str, lang: str): @@ -514,7 +531,7 @@ def get_data_API(session, league: str, team: str, lang: str): game_id = today_game["game"]["id"] logger.info(f"Онлайн матч id={game_id}") - # сразу получить стартовые данные перед циклом + # базовые данные прямо сейчас (до запуска фонового потока) fetch_api_data(session, "game", host=HOST, game_id=game_id, lang=lang) # fetch_api_data( # session, @@ -526,25 +543,1031 @@ def get_data_API(session, league: str, team: str, lang: str): # lang=lang, # ) - # если матч реально идёт -> запускаем быстрый опрос + # если матч реально идёт -> запускаем отдельный поток live-опроса if is_game_live(today_game["game"]): - poll_game_live( - session=session, - league=league, - season=season, - game_id=game_id, - lang=lang, - game_meta=today_game["game"], - ) - else: - logger.info( - "Матч ещё не стартовал, но сегодня. Просто сохранили стартовые данные." + t = threading.Thread( + target=run_live_loop, + args=(session, league, season, game_id, lang, today_game["game"]), + daemon=False, # <-- ключевое изменение ) + t.start() + logger.info("live thread spawned, waiting for it to finish...") + + # блокируем main до конца матча + t.join() + logger.info("live thread finished") return logger.info("Для этой команды игр сегодня нет и нет завершённой последней игры.") +def read_local_json(name: str, in_dir: str = "static"): + """ + Безопасно читает static/.json. + Если файла нет или он в процессе записи -> вернёт None, но не упадёт. + """ + filename = os.path.join(in_dir, f"{name}.json") + try: + with open(filename, "r", encoding="utf-8") as f: + return json.load(f) + except FileNotFoundError: + return None + except json.JSONDecodeError: + # файл мог быть в моменте перезаписи -> просто пропускаем этот тик + return None + except Exception as ex: + logger.exception(f"read_local_json({name}) error: {ex}") + return None + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def build_render_state() -> dict: + """ + Читает сырые api_*.json и собирает удобный json для другой программы (графики и т.п.). + + Возвращает dict, который потом пишем в static/ui_state.json + """ + game_data = read_local_json("api_game") + live_status_data = read_local_json("api_live-status") + box_score_data = read_local_json("api_box-score") + play_by_play_data = read_local_json("api_play-by-play") + + # базовый безопасный каркас + for index_team, team in enumerate(game_data["result"]["teams"][1:]): + box_team = box_score_data["result"]["teams"][index_team] + for player in team.get("starts", []): + stat = next( + ( + s + for s in box_team.get("starts", []) + if s.get("startNum") == player.get("startNum") + ), + None, + ) + if stat: + player["stats"] = stat + + team["total"] = box_team.get("total", {}) + + game_data["plays"] = play_by_play_data.get("result", []) + game_data["scoreByPeriods"] = box_score_data["result"].get("scoreByPeriods", []) + game_data["fullScore"] = box_score_data["result"].get("fullScore", {}) + game_data["live_status"] = live_status_data["result"] + merged: Dict[str, Any] = { + "meta": { + "generatedAt": _now_iso(), + "sourceHints": { + "boxScoreHas": "", + "pbpLen": "", + }, + }, + "result": game_data, + } + return merged + +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 Json_Team_Generation( + merged: dict, *, out_dir: str = "static", who: str | None = None +) -> None: + """ + Единая точка: принимает уже нормализованный merged, делает нужные вычисления (если надо) + и сохраняет в JSON. + """ + # Здесь можно делать любые расчёты/агрегации... + # Пример предохранителя: сортировка плей-бай-плея по sequence + # plays = merged.get("result", {}).get("plays", []) + # if plays and isinstance(plays, list): + # try: + # plays.sort(key=lambda e: (e.get("sequence") is None, e.get("sequence"), e.get("time") or e.get("clock"))) + # except Exception: + # pass + + # Имя файла + # print(merged) + # merged = + if who == "team1": + for i in merged["result"]["teams"]: + if i["teamNumber"] == 1: + payload = i + elif who == "team2": + for i in merged["result"]["teams"]: + if i["teamNumber"] == 2: + payload = i + # online = ( + # True + # if json_live_status + # and "status" in json_live_status + # and json_live_status["status"] == "Ok" + # and json_live_status["result"]["gameStatus"] == "Online" + # else False + # ) + online = False + role_list = [ + ("Center", "C"), + ("Guard", "G"), + ("Forward", "F"), + ("Power Forward", "PF"), + ("Small Forward", "SF"), + ("Shooting Guard", "SG"), + ("Point Guard", "PG"), + ("Forward-Center", "FC"), + ] + starts = payload["starts"] + team = [] + for item in starts: + player = { + "id": (item["personId"] if item["personId"] else ""), + "num": item["displayNumber"], + "startRole": item["startRole"], + "role": item["positionName"], + "roleShort": ( + [ + r[1] + for r in role_list + if r[0].lower() == item["positionName"].lower() + ][0] + if any(r[0].lower() == item["positionName"].lower() for r in role_list) + else "" + ), + "NameGFX": ( + f"{item['firstName'].strip()} {item['lastName'].strip()}" + if item["firstName"] is not None and item["lastName"] is not None + else "Команда" + ), + "captain": item["isCapitan"], + "age": item["age"] if item["age"] is not None else 0, + "height": f'{item["height"]} cm' if item["height"] else 0, + "weight": f'{item["weight"]} kg' if item["weight"] else 0, + "isStart": (item["stats"]["isStart"] if item["stats"] else False), + "isOn": ( + "🏀" if item["stats"] and item["stats"]["isOnCourt"] is True else "" + ), + "flag": f"https://flagicons.lipis.dev/flags/4x3/{'ru' if item['countryId'] is None and item['countryName'] == 'Russia' else '' if item['countryId'] is None else item['countryId'].lower() if item['countryName'] is not None else ''}.svg", + "pts": item["stats"]["points"] if item["stats"] else 0, + "pt-2": ( + f"{item['stats']['goal2']}/{item['stats']['shot2']}" + if item["stats"] + else 0 + ), + "pt-3": ( + f"{item['stats']['goal3']}/{item['stats']['shot3']}" + if item["stats"] + else 0 + ), + "pt-1": ( + f"{item['stats']['goal1']}/{item['stats']['shot1']}" + if item["stats"] + else 0 + ), + "fg": ( + f"{item['stats']['goal2'] + item['stats']['goal3']}/{item['stats']['shot2'] + item['stats']['shot3']}" + if item["stats"] + else 0 + ), + "ast": item["stats"]["assist"] if item["stats"] else 0, + "stl": item["stats"]["steal"] if item["stats"] else 0, + "blk": item["stats"]["block"] if item["stats"] else 0, + "blkVic": item["stats"]["blocked"] if item["stats"] else 0, + "dreb": item["stats"]["defReb"] if item["stats"] else 0, + "oreb": item["stats"]["offReb"] if item["stats"] else 0, + "reb": ( + item["stats"]["defReb"] + item["stats"]["offReb"] + if item["stats"] + else 0 + ), + "to": item["stats"]["turnover"] if item["stats"] else 0, + "foul": item["stats"]["foul"] if item["stats"] else 0, + "foulT": item["stats"]["foulT"] if item["stats"] else 0, + "foulD": item["stats"]["foulD"] if item["stats"] else 0, + "foulC": item["stats"]["foulC"] if item["stats"] else 0, + "foulB": item["stats"]["foulB"] if item["stats"] else 0, + "fouled": item["stats"]["foulsOn"] if item["stats"] else 0, + "plusMinus": item["stats"]["plusMinus"] if item["stats"] else 0, + "dunk": item["stats"]["dunk"] if item["stats"] else 0, + "kpi": ( + item["stats"]["points"] + + item["stats"]["defReb"] + + item["stats"]["offReb"] + + item["stats"]["assist"] + + item["stats"]["steal"] + + item["stats"]["block"] + + item["stats"]["foulsOn"] + + (item["stats"]["goal1"] - item["stats"]["shot1"]) + + (item["stats"]["goal2"] - item["stats"]["shot2"]) + + (item["stats"]["goal3"] - item["stats"]["shot3"]) + - item["stats"]["turnover"] + - item["stats"]["foul"] + if item["stats"] + else 0 + ), + "time": (format_time(item["stats"]["second"]) if item["stats"] else "0:00"), + "pts1q": 0, + "pts2q": 0, + "pts3q": 0, + "pts4q": 0, + "pts1h": 0, + "pts2h": 0, + "Name1GFX": (item["firstName"].strip() if item["firstName"] else ""), + "Name2GFX": (item["lastName"].strip() if item["lastName"] else ""), + "photoGFX": ( + os.path.join( + "D:\\Photos", + merged["result"]["league"]["abcName"], + merged["result"][who]["name"], + # LEAGUE, + # data[who], + f"{item['displayNumber']}.png", + ) + if item["startRole"] == "Player" + else "" + ), + # "season": text, + "isOnCourt": (item["stats"]["isOnCourt"] if item["stats"] else False), + # "AvgPoints": ( + # row_player_season_avg["points"] + # if row_player_season_avg + # and row_player_season_avg["points"] != "" + # else "0.0" + # ), + # "AvgAssist": ( + # row_player_season_avg["assist"] + # if row_player_season_avg + # and row_player_season_avg["assist"] != "" + # else "0.0" + # ), + # "AvgBlocks": ( + # row_player_season_avg["blockShot"] + # if row_player_season_avg + # and row_player_season_avg["blockShot"] != "" + # else "0.0" + # ), + # "AvgDefRebound": ( + # row_player_season_avg["defRebound"] + # if row_player_season_avg + # and row_player_season_avg["defRebound"] != "" + # else "0.0" + # ), + # "AvgOffRebound": ( + # row_player_season_avg["offRebound"] + # if row_player_season_avg + # and row_player_season_avg["offRebound"] != "" + # else "0.0" + # ), + # "AvgRebound": ( + # row_player_season_avg["rebound"] + # if row_player_season_avg + # and row_player_season_avg["rebound"] != "" + # else "0.0" + # ), + # "AvgSteal": ( + # row_player_season_avg["steal"] + # if row_player_season_avg + # and row_player_season_avg["steal"] != "" + # else "0.0" + # ), + # "AvgTurnover": ( + # row_player_season_avg["turnover"] + # if row_player_season_avg + # and row_player_season_avg["turnover"] != "" + # else "0.0" + # ), + # "AvgFoul": ( + # row_player_season_avg["foul"] + # if row_player_season_avg + # and row_player_season_avg["foul"] != "" + # else "0.0" + # ), + # "AvgOpponentFoul": ( + # row_player_season_avg["foulsOnPlayer"] + # if row_player_season_avg + # and row_player_season_avg["foulsOnPlayer"] != "" + # else "0.0" + # ), + # "AvgPlusMinus": ( + # row_player_season_avg["plusMinus"] + # if row_player_season_avg + # and row_player_season_avg["plusMinus"] != "" + # else "0.0" + # ), + # "AvgDunk": ( + # row_player_season_avg["dunk"] + # if row_player_season_avg + # and row_player_season_avg["dunk"] != "" + # else "0.0" + # ), + # "AvgKPI": "0.0", + # "AvgPlayedTime": ( + # row_player_season_avg["playedTime"] + # if row_player_season_avg + # and row_player_season_avg["playedTime"] != "" + # else "0:00" + # ), + # "Shot1Percent": calc_shot_percent_by_type( + # sum_stat, item["stats"], online, shot_types=1 + # ), + # "Shot2Percent": calc_shot_percent_by_type( + # sum_stat, item["stats"], online, shot_types=2 + # ), + # "Shot3Percent": calc_shot_percent_by_type( + # sum_stat, item["stats"], online, shot_types=3 + # ), + # "Shot23Percent": calc_shot_percent_by_type( + # sum_stat, item["stats"], online, shot_types=[2, 3] + # ), + # "TPoints": sum_stat_with_online( + # "points", sum_stat, item["stats"], online + # ), + # "TShots1": calc_total_shots_str( + # sum_stat, item["stats"], online, 1 + # ), + # "TShots2": calc_total_shots_str( + # sum_stat, item["stats"], online, 2 + # ), + # "TShots3": calc_total_shots_str( + # sum_stat, item["stats"], online, 3 + # ), + # "TShots23": calc_total_shots_str( + # sum_stat, item["stats"], online, [2, 3] + # ), + # "TShot1Percent": calc_shot_percent_by_type( + # sum_stat, item["stats"], online, shot_types=1 + # ), + # "TShot2Percent": calc_shot_percent_by_type( + # sum_stat, item["stats"], online, shot_types=2 + # ), + # "TShot3Percent": calc_shot_percent_by_type( + # sum_stat, item["stats"], online, shot_types=3 + # ), + # "TShot23Percent": calc_shot_percent_by_type( + # sum_stat, item["stats"], online, shot_types=[2, 3] + # ), + # "TAssist": sum_stat_with_online( + # "assist", sum_stat, item["stats"], online + # ), + # "TBlocks": sum_stat_with_online( + # "blockShot", sum_stat, item["stats"], online + # ), + # "TDefRebound": sum_stat_with_online( + # "defRebound", sum_stat, item["stats"], online + # ), + # "TOffRebound": sum_stat_with_online( + # "offRebound", sum_stat, item["stats"], online + # ), + # "TRebound": ( + # sum_stat_with_online( + # "defRebound", sum_stat, item["stats"], online + # ) + # + sum_stat_with_online( + # "offRebound", sum_stat, item["stats"], online + # ) + # ), + # "TSteal": sum_stat_with_online( + # "steal", sum_stat, item["stats"], online + # ), + # "TTurnover": sum_stat_with_online( + # "turnover", sum_stat, item["stats"], online + # ), + # "TFoul": sum_stat_with_online( + # "foul", sum_stat, item["stats"], online + # ), + # "TOpponentFoul": sum_stat_with_online( + # "foulsOnPlayer", sum_stat, item["stats"], online + # ), + # "TPlusMinus": 0, + # "TDunk": sum_stat_with_online( + # "dunk", sum_stat, item["stats"], online + # ), + # "TKPI": 0, + # "TPlayedTime": sum_stat["playedTime"] if sum_stat else "0:00", + # "TGameCount": ( + # safe_int(sum_stat["games"]) + # if sum_stat and sum_stat.get("games") != "" + # else 0 + # ) + # + (1 if online else 0), + # "TStartCount": ( + # safe_int(sum_stat["isStarts"]) + # if sum_stat and sum_stat.get("isStarts", 0) != "" + # else 0 + # ), + # "CareerTShots1": calc_total_shots_str( + # row_player_career_sum, item["stats"], online, 1 + # ), + # "CareerTShots2": calc_total_shots_str( + # row_player_career_sum, item["stats"], online, 2 + # ), + # "CareerTShots3": calc_total_shots_str( + # row_player_career_sum, item["stats"], online, 3 + # ), + # "CareerTShots23": calc_total_shots_str( + # row_player_career_sum, item["stats"], online, [2, 3] + # ), + # "CareerTShot1Percent": calc_shot_percent_by_type( + # row_player_career_sum, item["stats"], online, 1 + # ), + # "CareerTShot2Percent": calc_shot_percent_by_type( + # row_player_career_sum, item["stats"], online, 2 + # ), + # "CareerTShot3Percent": calc_shot_percent_by_type( + # row_player_career_sum, item["stats"], online, 3 + # ), + # "CareerTShot23Percent": calc_shot_percent_by_type( + # row_player_career_sum, item["stats"], online, [2, 3] + # ), + # "CareerTPoints": sum_stat_with_online( + # "points", row_player_career_sum, item["stats"], online + # ), + # "CareerTAssist": sum_stat_with_online( + # "assist", row_player_career_sum, item["stats"], online + # ), + # "CareerTBlocks": sum_stat_with_online( + # "blockShot", row_player_career_sum, item["stats"], online + # ), + # "CareerTDefRebound": sum_stat_with_online( + # "defRebound", row_player_career_sum, item["stats"], online + # ), + # "CareerTOffRebound": sum_stat_with_online( + # "offRebound", row_player_career_sum, item["stats"], online + # ), + # "CareerTRebound": ( + # sum_stat_with_online( + # "defRebound", + # row_player_career_sum, + # item["stats"], + # online, + # ) + # + sum_stat_with_online( + # "offRebound", + # row_player_career_sum, + # item["stats"], + # online, + # ) + # ), + # "CareerTSteal": sum_stat_with_online( + # "steal", row_player_career_sum, item["stats"], online + # ), + # "CareerTTurnover": sum_stat_with_online( + # "turnover", row_player_career_sum, item["stats"], online + # ), + # "CareerTFoul": sum_stat_with_online( + # "foul", row_player_career_sum, item["stats"], online + # ), + # "CareerTOpponentFoul": sum_stat_with_online( + # "foulsOnPlayer", + # row_player_career_sum, + # item["stats"], + # online, + # ), + # "CareerTPlusMinus": 0, # оставить как есть + # "CareerTDunk": sum_stat_with_online( + # "dunk", row_player_career_sum, item["stats"], online + # ), + # "CareerTPlayedTime": ( + # row_player_career_sum["playedTime"] + # if row_player_career_sum + # else "0:00" + # ), + # "CareerTGameCount": sum_stat_with_online( + # "games", row_player_career_sum, item["stats"], online + # ) + # + (1 if online else 0), + # "CareerTStartCount": sum_stat_with_online( + # "isStarts", row_player_career_sum, item["stats"], online + # ), # если нужно, можно +1 при старте + # "AvgCarPoints": ( + # row_player_career_avg["points"] + # if row_player_career_avg + # and row_player_career_avg["points"] != "" + # else "0.0" + # ), + # "AvgCarAssist": ( + # row_player_career_avg["assist"] + # if row_player_career_avg + # and row_player_career_avg["assist"] != "" + # else "0.0" + # ), + # "AvgCarBlocks": ( + # row_player_career_avg["blockShot"] + # if row_player_career_avg + # and row_player_career_avg["blockShot"] != "" + # else "0.0" + # ), + # "AvgCarDefRebound": ( + # row_player_career_avg["defRebound"] + # if row_player_career_avg + # and row_player_career_avg["defRebound"] != "" + # else "0.0" + # ), + # "AvgCarOffRebound": ( + # row_player_career_avg["offRebound"] + # if row_player_career_avg + # and row_player_career_avg["offRebound"] != "" + # else "0.0" + # ), + # "AvgCarRebound": ( + # row_player_career_avg["rebound"] + # if row_player_career_avg + # and row_player_career_avg["rebound"] != "" + # else "0.0" + # ), + # "AvgCarSteal": ( + # row_player_career_avg["steal"] + # if row_player_career_avg + # and row_player_career_avg["steal"] != "" + # else "0.0" + # ), + # "AvgCarTurnover": ( + # row_player_career_avg["turnover"] + # if row_player_career_avg + # and row_player_career_avg["turnover"] != "" + # else "0.0" + # ), + # "AvgCarFoul": ( + # row_player_career_avg["foul"] + # if row_player_career_avg + # and row_player_career_avg["foul"] != "" + # else "0.0" + # ), + # "AvgCarOpponentFoul": ( + # row_player_career_avg["foulsOnPlayer"] + # if row_player_career_avg + # and row_player_career_avg["foulsOnPlayer"] != "" + # else "0.0" + # ), + # "AvgCarPlusMinus": ( + # row_player_career_avg["plusMinus"] + # if row_player_career_avg + # and row_player_career_avg["plusMinus"] != "" + # else "0.0" + # ), + # "AvgCarDunk": ( + # row_player_career_avg["dunk"] + # if row_player_career_avg + # and row_player_career_avg["dunk"] != "" + # else "0.0" + # ), + # "AvgCarKPI": "0.0", + # "AvgCarPlayedTime": ( + # row_player_career_avg["playedTime"] + # if row_player_career_avg + # and row_player_career_avg["playedTime"] != "" + # else "0:00" + # ), + # "HeadCoachStatsCareer": HeadCoachStatsCareer, + # "HeadCoachStatsTeam": HeadCoachStatsTeam, + # # "PTS_Career_High": get_carrer_high(item["personId"], "points"), + # # "AST_Career_High": get_carrer_high(item["personId"], "assist"), + # # "REB_Career_High": get_carrer_high(item["personId"], "rebound"), + # # "STL_Career_High": get_carrer_high(item["personId"], "steal"), + # # "BLK_Career_High": get_carrer_high(item["personId"], "blockShot"), + } + team.append(player) + count_player = sum(1 for x in team if x["startRole"] == "Player") + # print(count_player) + if count_player < 12: + if team: # Check if team is not empty + empty_rows = [ + { + key: ( + False + if key in ["captain", "isStart", "isOnCourt"] + else ( + 0 + if key + in [ + "id", + "pts", + "weight", + "height", + "age", + "ast", + "stl", + "blk", + "blkVic", + "dreb", + "oreb", + "reb", + "to", + "foul", + "foulT", + "foulD", + "foulC", + "foulB", + "fouled", + "plusMinus", + "dunk", + "kpi", + ] + else "" + ) + ) + for key in team[0].keys() + } + for _ in range((4 if count_player <= 4 else 12) - count_player) + ] + team.extend(empty_rows) + role_priority = { + "Player": 0, + "": 1, + "Coach": 2, + "Team": 3, + None: 4, + "Other": 5, # на случай неизвестных + } + # print(team) + sorted_team = sorted( + team, + key=lambda x: role_priority.get( + x.get("startRole", 99), 99 + ), # 99 — по умолчанию + ) + out_path = Path(out_dir) / f"{who}.json" + atomic_write_json(out_path, sorted_team) + logging.info("Сохранил payload: {out_path}") + + top_sorted_team = sorted( + filter(lambda x: x["startRole"] in ["Player", ""], sorted_team), + key=lambda x: ( + x["pts"], + x["dreb"] + x["oreb"], + x["ast"], + x["stl"], + x["blk"], + x["time"], + ), + reverse=True, + ) + for item in top_sorted_team: + item["pts"] = "" if item["num"] == "" else item["pts"] + item["foul"] = "" if item["num"] == "" else item["foul"] + + out_path = Path(out_dir) / f"top{who.replace('t','T')}.json" + atomic_write_json(out_path, top_sorted_team) + logging.info("Сохранил payload: {out_path}") + + started_team = sorted( + filter( + lambda x: x["startRole"] == "Player" and x["isOnCourt"] is True, + sorted_team, + ), + key=lambda x: int(x["num"]), + reverse=False, + ) + + out_path = Path(out_dir) / f"started_{who}.json" + atomic_write_json(out_path, started_team) + logging.info("Сохранил payload: {out_path}") + + +def time_outs_func(data_pbp: list[dict]) -> tuple[str, int, str, int]: + """ + Вычисляет количество оставшихся таймаутов для обеих команд + и формирует строку состояния. + + Args: + data_pbp: Список игровых событий (play-by-play). + + Returns: + Кортеж: (строка команды 1, остаток, строка команды 2, остаток) + """ + timeout1 = [] + timeout2 = [] + + for event in data_pbp: + if event.get("play") == 23: + if event.get("startNum") == 1: + timeout1.append(event) + elif event.get("startNum") == 2: + timeout2.append(event) + + def timeout_status(timeout_list: list[dict], last_event: dict) -> tuple[str, int]: + period = last_event.get("period", 0) + sec = last_event.get("sec", 0) + + if period < 3: + timeout_max = 2 + count = sum(1 for t in timeout_list if t.get("period", 0) <= period) + quarter = "1st half" + elif period < 5: + count = sum(1 for t in timeout_list if 3 <= t.get("period", 0) <= period) + quarter = "2nd half" + if period == 4 and sec >= 4800 and count in (0, 1): + timeout_max = 2 + else: + timeout_max = 3 + else: + timeout_max = 1 + count = sum(1 for t in timeout_list if t.get("period", 0) == period) + quarter = f"OverTime {period - 4}" + + left = max(0, timeout_max - count) + word = "Time-outs" if left != 1 else "Time-out" + text = f"{left if left != 0 else 'No'} {word} left in {quarter}" + return text, left + + if not data_pbp: + return "", 0, "", 0 + + last_event = data_pbp[-1] + t1_str, t1_left = timeout_status(timeout1, last_event) + t2_str, t2_left = timeout_status(timeout2, last_event) + + return t1_str, t1_left, t2_str, t2_left + + +def add_data_for_teams(new_data: list[dict]) -> tuple[float, list, float]: + """ + Возвращает усреднённые статистики команды: + - средний возраст + - очки со старта и скамейки + их доли + - средний рост + + Args: + new_data (list[dict]): Список игроков с полями "startRole", "stats", "age", "height" + + Returns: + tuple: (avg_age: float, points: list, avg_height: float) + """ + players = [item for item in new_data if item.get("startRole") == "Player"] + + points_start = 0 + points_bench = 0 + total_age = 0 + total_height = 0 + player_count = len(players) + + for player in players: + stats = player.get("stats") + if stats: + is_start = stats.get("isStart") + + # Очки + if is_start is True: + points_start += stats.get("points", 0) + elif is_start is False: + points_bench += stats.get("points", 0) + + # Возраст и рост + total_age += player.get("age", 0) or 0 + total_height += player.get("height", 0) or 0 + + total_points = points_start + points_bench + points_start_pro = ( + f"{round(points_start * 100 / total_points)}%" if total_points else "0%" + ) + points_bench_pro = ( + f"{round(points_bench * 100 / total_points)}%" if total_points else "0%" + ) + + avg_age = round(total_age / player_count, 1) if player_count else 0 + avg_height = round(total_height / player_count, 1) if player_count else 0 + + points = [points_start, points_start_pro, points_bench, points_bench_pro] + return avg_age, points, avg_height + + +def add_new_team_stat( + data: dict, + avg_age: float, + points: float, + avg_height: float, + timeout_str: str, + timeout_left: str, +) -> dict: + """ + Добавляет в словарь команды форматированную статистику. + Все значения приводятся к строкам. + + Args: + data: Исходная статистика команды. + avg_age: Средний возраст команды (строка). + points: Кортеж из 4 строк: ptsStart, ptsStart_pro, ptsBench, ptsBench_pro. + avg_height: Средний рост (в см). + timeout_str: Строка отображения таймаутов. + timeout_left: Остаток таймаутов. + + Returns: + Обновлённый словарь `data` с новыми ключами. + """ + + def safe_int(v): # Локальная защита от ValueError/TypeError + try: + return int(v) + except (ValueError, TypeError): + return 0 + + def format_percent(goal, shot): + goal, shot = safe_int(goal), safe_int(shot) + return f"{round(goal * 100 / shot)}%" if shot else "0%" + + goal1, shot1 = safe_int(data.get("goal1")), safe_int(data.get("shot1")) + goal2, shot2 = safe_int(data.get("goal2")), safe_int(data.get("shot2")) + goal3, shot3 = safe_int(data.get("goal3")), safe_int(data.get("shot3")) + + def_reb = safe_int(data.get("defReb")) + off_reb = safe_int(data.get("offReb")) + + data.update( + { + "pt-1": f"{goal1}/{shot1}", + "pt-2": f"{goal2}/{shot2}", + "pt-3": f"{goal3}/{shot3}", + "fg": f"{goal2 + goal3}/{shot2 + shot3}", + "pt-1_pro": format_percent(goal1, shot1), + "pt-2_pro": format_percent(goal2, shot2), + "pt-3_pro": format_percent(goal3, shot3), + "fg_pro": format_percent(goal2 + goal3, shot2 + shot3), + "Reb": str(def_reb + off_reb), + "avgAge": str(avg_age), + "ptsStart": str(points[0]), + "ptsStart_pro": str(points[1]), + "ptsBench": str(points[2]), + "ptsBench_pro": str(points[3]), + "avgHeight": f"{avg_height} cm", + "timeout_left": str(timeout_left), + "timeout_str": str(timeout_str), + } + ) + + # Приводим все значения к строкам, если нужно строго для сериализации + for k in data: + data[k] = str(data[k]) + + return data + + +stat_name_list = [ + ("points", "Очки", "points"), + ("pt-1", "Штрафные", "free throws"), + ("pt-1_pro", "штрафные, процент", "free throws pro"), + ("pt-2", "2-очковые", "2-points"), + ("pt-2_pro", "2-очковые, процент", "2-points pro"), + ("pt-3", "3-очковые", "3-points"), + ("pt-3_pro", "3-очковые, процент", "3-points pro"), + ("fg", "очки с игры", "field goals"), + ("fg_pro", "Очки с игры, процент", "field goals pro"), + ("assist", "Передачи", "assists"), + ("pass", "", ""), + ("defReb", "подборы в защите", ""), + ("offReb", "подборы в нападении", ""), + ("Reb", "Подборы", "rebounds"), + ("steal", "Перехваты", "steals"), + ("block", "Блокшоты", "blocks"), + ("blocked", "", ""), + ("turnover", "Потери", "turnovers"), + ("foul", "Фолы", "fouls"), + ("foulsOn", "", ""), + ("foulT", "", ""), + ("foulD", "", ""), + ("foulC", "", ""), + ("foulB", "", ""), + ("second", "секунды", "seconds"), + ("dunk", "данки", "dunks"), + ("fastBreak", "", "fast breaks"), + ("plusMinus", "+/-", "+/-"), + ("avgAge", "", "avg Age"), + ("ptsBench", "", "Bench PTS"), + ("ptsBench_pro", "", "Bench PTS, %"), + ("ptsStart", "", "Start PTS"), + ("ptsStart_pro", "", "Start PTS, %"), + ("avgHeight", "", "avg height"), + ("timeout_left", "", "timeout left"), + ("timeout_str", "", "timeout str"), +] + + +def Team_Both_Stat(merged: dict, *, out_dir: str = "static") -> None: + """ + Обновляет файл team_stats.json, содержащий сравнение двух команд. + + Аргументы: + stop_event (threading.Event): Событие для остановки цикла. + """ + logger.info("START making json for team statistics") + + try: + teams = merged["result"]["teams"] + plays = merged["result"].get("plays", []) + + # Разделение команд + team_1 = next((t for t in teams if t["teamNumber"] == 1), None) + team_2 = next((t for t in teams if t["teamNumber"] == 2), None) + + if not team_1 or not team_2: + logger.warning("Не найдены обе команды в данных") + # time.sleep() + + # Таймауты + timeout_str1, timeout_left1, timeout_str2, timeout_left2 = time_outs_func(plays) + + # Возраст, очки, рост + avg_age_1, points_1, avg_height_1 = add_data_for_teams(team_1.get("starts", [])) + avg_age_2, points_2, avg_height_2 = add_data_for_teams(team_2.get("starts", [])) + + if not team_1.get("total") or not team_2.get("total"): + logger.debug("Нет total у команд — пропускаю перезапись team_stats.json") + + # Форматирование общей статистики (как и было) + total_1 = add_new_team_stat( + team_1["total"], + avg_age_1, + points_1, + avg_height_1, + timeout_str1, + timeout_left1, + ) + total_2 = add_new_team_stat( + team_2["total"], + avg_age_2, + points_2, + avg_height_2, + timeout_str2, + timeout_left2, + ) + + # Финальный JSON + result_json = [] + for key in total_1: + val1 = ( + int(total_1[key]) if isinstance(total_1[key], float) else total_1[key] + ) + val2 = ( + int(total_2[key]) if isinstance(total_2[key], float) else total_2[key] + ) + stat_rus, stat_eng = "", "" + for s in stat_name_list: + if s[0] == key: + stat_rus, stat_eng = s[1], s[2] + break + + result_json.append( + { + "name": key, + "nameGFX_rus": stat_rus, + "nameGFX_eng": stat_eng, + "val1": val1, + "val2": val2, + } + ) + + out_path = Path(out_dir) / "team_stats.json" + atomic_write_json(out_path, result_json) + logging.info("Сохранил payload: {out_path}") + + logger.debug("Успешно записаны данные в team_stats.json") + except Exception as e: + logger.error(f"Ошибка при обработке командной статистики: {e}", exc_info=True) + + + +def render_loop(stop_event: threading.Event, out_name: str = "ui_state"): + """ + Крутится в отдельном потоке. + Постоянно читает сырые api_*.json, собирает финальный state + и сохраняет в static/.json. + Работает, пока stop_event не установлен. + """ + logger.info("[RENDER_THREAD] start render loop") + + while not stop_event.is_set(): + try: + state = build_render_state() + Team_Both_Stat(state) + Json_Team_Generation(state, who="team1") + Json_Team_Generation(state, who="team2") + atomic_write_json(state, out_name) + + except Exception as ex: + logger.exception(f"[RENDER_THREAD] error while building render state: {ex}") + + # частота обновления отрисовки. + # 0.2с достаточно быстро для ТВ-графики и не жарит CPU. + time.sleep(0.2) + + logger.info("[RENDER_THREAD] stop render loop") + + def main(): parser = argparse.ArgumentParser() parser.add_argument("--league", default="vtb")