diff --git a/get_data_new.py b/get_data_new.py index d10c50e..f944380 100644 --- a/get_data_new.py +++ b/get_data_new.py @@ -1,32 +1,41 @@ import time -from datetime import datetime, timedelta, timezone -from requests.adapters import HTTPAdapter -from urllib3.util.retry import Retry -import requests -import json import os +import json import tempfile import argparse import platform -import sys import logging -import pandas as pd import logging.config -from typing import Any, Dict, List +from datetime import datetime, timedelta, timezone from zoneinfo import ZoneInfo +from typing import Any, Dict, List, Tuple, Optional + +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry import threading from concurrent.futures import ThreadPoolExecutor, as_completed + +# ============================================================================ +# 1. Константы / глобальные объекты +# ============================================================================ + HOST = "https://ref.russiabasket.org" + +# Таймзона, в которой мы считаем время матчей / расписания / сна до завтра APP_TZ = ZoneInfo("Europe/Moscow") + MYHOST = platform.node() + TELEGRAM_BOT_TOKEN = "7639240596:AAH0YtdQoWZSC-_R_EW4wKAHHNLIA0F_ARY" # TELEGRAM_CHAT_ID = 228977654 TELEGRAM_CHAT_ID = -4803699526 -if not os.path.exists("logs"): - os.makedirs("logs") +# Глобальный лок для потокобезопасной записи JSON _write_lock = threading.Lock() + +# Карта всех ручек API, с интервалами опроса в секундах. URLS = [ { "name": "seasons", @@ -75,6 +84,14 @@ URLS = [ }, ] + +# ============================================================================ +# 2. Логирование +# ============================================================================ + +if not os.path.exists("logs"): + os.makedirs("logs") + LOG_CONFIG = { "version": 1, "handlers": { @@ -121,7 +138,72 @@ logger = logging.getLogger(__name__) logger.handlers[2].formatter.use_emoji = True +# ============================================================================ +# 3. I/O вспомогательные функции +# ============================================================================ + +def atomic_write_json(data: Any, name: str, out_dir: str = "static") -> None: + """ + Потокобезопасная запись JSON в static/.json. + + Запись делается через временный файл + os.replace, чтобы: + - читатели не получили битый файл во время перезаписи; + - не было гонок между render_loop и poll_game_live. + """ + os.makedirs(out_dir, exist_ok=True) + filename = os.path.join(out_dir, f"{name}.json") + + with _write_lock: + with tempfile.NamedTemporaryFile( + "w", delete=False, dir=out_dir, encoding="utf-8" + ) as tmp_file: + json.dump(data, tmp_file, ensure_ascii=False, indent=2) + tmp_file.flush() + os.fsync(tmp_file.fileno()) + tmp_name = tmp_file.name + + os.replace(tmp_name, filename) + + +def read_local_json(name: str, in_dir: str = "static") -> Optional[dict]: + """ + Безопасно читает static/.json. + + Возвращает dict или None. + Не кидает исключение, если файл не существует или был в моменте перезаписи. + Это важно для render_loop, который читает файлы параллельно с poll_game_live. + """ + 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: + """ + Возвращает текущее время в ISO-формате UTC ("2025-10-27T12:34:56Z"). + Это кладётся в итоговый JSON как метаданные генерации. + """ + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +# ============================================================================ +# 4. Работа с HTTP / API +# ============================================================================ + def create_session() -> requests.Session: + """ + Создаёт requests.Session с ретраями и дефолтными заголовками. + Эту сессию потом используем для всех запросов (в том числе в live-пуле). + """ session = requests.Session() retries = Retry( @@ -143,7 +225,24 @@ def create_session() -> requests.Session: return session -def get_json(session, url: str, name: str): +def build_url(name: str, **kwargs) -> str: + """ + Собрать конечный URL по имени эндпоинта из URLS. + Пример: + build_url("standings", host=..., league=..., season=..., lang=...) + """ + template = next((u["url"] for u in URLS if u["name"] == name), None) + if not template: + raise ValueError(f"Unknown URL name: {name}") + return template.format(**kwargs) + + +def get_json(session: requests.Session, url: str, name: str) -> Any: + """ + Выполняет GET к API, падает, если HTTP != 2xx, + складывает ответ в static/api_.json (сырой ответ API), + и возвращает распарсенный json. + """ resp = session.get(url, timeout=10) resp.raise_for_status() data = resp.json() @@ -151,83 +250,69 @@ def get_json(session, url: str, name: str): return data -def build_url(name: str, **kwargs) -> str: +def get_items(data: dict) -> Optional[list]: """ - Собрать конечный URL по имени ручки из URLS. - Пример: - build_url("standings", host=..., league=..., season=..., lang=...) + Мелкий хелпер: берём первый список в ответе API. + Многие ручки отдают {"result":[...]} или {"seasons":[...]}. + Если находим список — возвращаем его. + Если нет — возвращаем None (значит, нужно брать весь dict). """ - template = next((u["url"] for u in URLS if u["name"] == name), None) - if not template: - raise ValueError(f"Unknown URL name: {name}") - - return template.format(**kwargs) - - -def atomic_write_json(data, name: str, out_dir: str = "static"): - """ - Сохраняет data в static/.json атомарно. - Потокобезопасно (глобальный lock). - """ - os.makedirs(out_dir, exist_ok=True) - filename = os.path.join(out_dir, f"{name}.json") - - with _write_lock: - with tempfile.NamedTemporaryFile( - "w", delete=False, dir=out_dir, encoding="utf-8" - ) as tmp_file: - json.dump(data, tmp_file, ensure_ascii=False, indent=2) - tmp_file.flush() - os.fsync(tmp_file.fileno()) - tmp_name = tmp_file.name - - os.replace(tmp_name, filename) - - -def get_items(data): for k, v in data.items(): if isinstance(v, list): return data[k] + return None -def poll_one_endpoint(session, endpoint_name, league, season, game_id, lang): +def fetch_api_data(session: requests.Session, name: str, name_save: str = None, **kwargs) -> Any: """ - Дёрнуть конкретный endpoint и вернуть (endpoint_name, data или None) + Универсальный обёртчик над API: + - строит URL по имени ручки, + - тянет данные через get_json(), + - ищет "главный" список (get_items), + - возвращает список или весь dict. + + Параллельно пишет в static/api_.json (через get_json()). + """ + url = build_url(name, **kwargs) + try: + json_data = get_json(session, url, name_save or name) + if json_data: + items = get_items(json_data) + return items if items is not None else json_data + return None + except Exception as ex: + logger.error(f"{url} | {ex}") + + +def poll_one_endpoint( + session: requests.Session, + endpoint_name: str, + league: str, + season: str, + game_id: int, + lang: str, +) -> Tuple[str, Any]: + """ + Вызывает конкретный эндпоинт (box-score, live-status, play-by-play и т.д.), + возвращает кортеж (имя_эндпоинта, данные_или_None). + + Используется внутри poll_game_live() для параллельного опроса API. """ if endpoint_name == "live-status": - data = fetch_api_data( - session, - "live-status", - host=HOST, - game_id=game_id, - ) + data = fetch_api_data(session, "live-status", host=HOST, game_id=game_id) return endpoint_name, data if endpoint_name == "box-score": - data = fetch_api_data( - session, - "box-score", - host=HOST, - game_id=game_id, - ) + data = fetch_api_data(session, "box-score", host=HOST, game_id=game_id) return endpoint_name, data if endpoint_name == "play-by-play": - data = fetch_api_data( - session, - "play-by-play", - host=HOST, - game_id=game_id, - ) + data = fetch_api_data(session, "play-by-play", host=HOST, game_id=game_id) return endpoint_name, data if endpoint_name == "game": data = fetch_api_data( - session, - "game", - host=HOST, - game_id=game_id, - lang=lang, + session, "game", host=HOST, game_id=game_id, lang=lang ) return endpoint_name, data @@ -243,39 +328,37 @@ def poll_one_endpoint(session, endpoint_name, league, season, game_id, lang): ) return endpoint_name, data - # fallback — вдруг добавим что-то ещё + # fallback — "ну вдруг добавим что-то ещё" data = fetch_api_data(session, endpoint_name, host=HOST, game_id=game_id, lang=lang) return endpoint_name, data -def fetch_api_data(session, name: str, name_save: str = None, **kwargs): +def get_interval_by_name(name: str) -> int: """ - Универсальная функция для получения данных с API: - 1. Собирает URL по имени ручки - 2. Получает JSON - 3. Возвращает основной список данных (если есть) + Возвращает рекомендуемый интервал опроса эндпоинта в секундах, + как задано в URLS. """ - url = build_url(name, **kwargs) - try: - json_data = get_json(session, url, name_save or name) - if json_data: - items = get_items(json_data) - return items if items is not None else json_data - return None - except Exception as ex: - logger.error(f"{url} | {ex}") + for u in URLS: + if u["name"] == name: + return u["interval"] + raise ValueError(f"interval not found for {name}") +# ============================================================================ +# 5. Работа с расписанием / статусом матча +# ============================================================================ + def parse_game_start_dt(item: dict) -> datetime: """ - Достаёт дату/время начала матча из объекта расписания и приводит к APP_TZ. - Приоритет полей: - 1) game.defaultZoneDateTime — уже в "дефолтной зоне" лиги (например, +03:00) - 2) game.scheduledTime — ISO 8601 с оффсетом (например, 2025-09-30T19:00:00+04:00) - 3) game.startTime — если API когда-то его заполняет - 4) (fallback) game.localDate + game.localTime — считаем, что это локальное время площадки, задаём tz=APP_TZ + Достаёт дату/время начала матча из объекта календаря и нормализует в APP_TZ. - Возвращает aware-datetime в APP_TZ. + Источники времени в порядке приоритета: + 1. game.defaultZoneDateTime (обычно уже с таймзоной лиги) + 2. game.scheduledTime (ISO8601 с оффсетом) + 3. game.startTime + 4. (fallback) game.localDate + game.localTime (считаем как APP_TZ) + + Возвращает timezone-aware datetime в APP_TZ. """ g = item.get("game", {}) if "game" in item else item @@ -287,7 +370,7 @@ def parse_game_start_dt(item: dict) -> datetime: except Exception as e: raise RuntimeError(f"Ошибка парсинга ISO времени '{raw}': {e}") - # Fallback: localDate + localTime (пример: "30.09.2025" + "19:00") + # fallback: localDate + localTime, формата "30.09.2025" + "19:00" ld, lt = g.get("localDate"), g.get("localTime") if ld and lt: try: @@ -298,14 +381,26 @@ def parse_game_start_dt(item: dict) -> datetime: raise RuntimeError(f"Ошибка парсинга localDate/localTime '{ld} {lt}': {e}") raise RuntimeError( - "Не найдено ни одного подходящего поля времени (defaultZoneDateTime/scheduledTime/startTime/localDate+localTime)." + "Не найдено ни одного подходящего поля времени (defaultZoneDateTime/" + "scheduledTime/startTime/localDate+localTime)." ) -def get_game_id(team_games: list[dict], team: str) -> tuple[dict | None, dict | None]: +def get_game_id( + team_games: List[dict], + team: str, +) -> Tuple[Optional[dict], Optional[dict]]: """ - Принимаем все расписание и ищем для домашней команды game_id. - Если сегодня нет матча, то берем game_id прошлой игры. + Находим интересующую нас игру. + + Логика (важно): + - считаем, что интересующая нас команда — это team1 (домашняя), + и сравниваем по имени. + - если есть игра сегодня -> это today_game + - иначе берём последнюю уже завершённую игру -> last_played + - возвращаем (today_game, last_played) + + Если и того и другого нет -> (None, None). """ now = datetime.now(APP_TZ) today = now.date() @@ -315,6 +410,8 @@ def get_game_id(team_games: list[dict], team: str) -> tuple[dict | None, dict | for g in team_games: start = parse_game_start_dt(g) status = g.get("game", {}).get("gameStatus", "").lower() + + # Игра сегодня для этой команды if ( start.date() == today and today_game is None @@ -322,6 +419,8 @@ def get_game_id(team_games: list[dict], team: str) -> tuple[dict | None, dict | ): today_game = g last_played = None + + # Последняя завершённая игра (resultconfirmed) elif ( start <= now and status == "resultconfirmed" @@ -329,20 +428,21 @@ def get_game_id(team_games: list[dict], team: str) -> tuple[dict | None, dict | ): today_game = None last_played = g + return today_game, last_played def is_game_live(game_obj: dict) -> bool: """ - Пытаемся понять, идёт ли сейчас матч. - game_obj ожидается как today_game["game"] (из calendar). + Пытаемся понять, идёт ли матч прямо сейчас. + + Правила: + - 'resultconfirmed' / 'finished' / 'result' => матч уже окончен + - 'scheduled' / 'notstarted' / 'draft' => матч ещё не начался + - всё остальное считаем лайвом (в том числе 'online', 'inprogress', и т.п.) """ status = (game_obj.get("gameStatus") or "").lower() - # эвристика: - # - "resultconfirmed" -> матч кончился - # - "scheduled" / "notstarted" -> ещё не начался - # всё остальное считаем лайвом if status in ("resultconfirmed", "finished", "result"): return False if status in ("scheduled", "notstarted", "draft"): @@ -350,128 +450,48 @@ def is_game_live(game_obj: dict) -> bool: return True -def get_interval_by_name(name: str) -> int: - """ - вернуть interval из URLS по имени ручки - """ - for u in URLS: - if u["name"] == name: - return u["interval"] - raise ValueError(f"interval not found for {name}") - - -def run_live_loop( - league: str, - season: str, - game_id: int, - lang: str, - game_meta: dict, - stop_event: threading.Event, -): - """ - Запускает два рабочих цикла: - - poll_game_live (опрашивает API матча) - - render_loop (собирает ui_state.json) - Управляет их остановкой. - """ - logger.info( - f"[LIVE_THREAD] start live loop for game_id={game_id} (league={league}, season={season})" - ) - - # отдельная сессия только для лайва - session = create_session() - - # поток рендера - render_thread = threading.Thread( - target=render_loop, - args=(stop_event,), # дефолт out_name="game" в твоём render_loop можно оставить - daemon=False, - ) - render_thread.start() - logger.info("[LIVE_THREAD] render thread spawned") - - try: - poll_game_live( - session=session, - league=league, - season=season, - game_id=game_id, - lang=lang, - game_meta=game_meta, - stop_event=stop_event, - ) - 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] stopping render thread for game_id={game_id}") - render_thread.join() - logger.info(f"[LIVE_THREAD] stop live loop for game_id={game_id}") - - -def Scores_Quarter(merged: dict, *, out_dir: str = "static") -> None: - """ - Поток, обновляющий JSON со счётом по четвертям. - """ - logger.info("START making json for scores quarter") - - quarters = ["Q1", "Q2", "Q3", "Q4", "OT1", "OT2", "OT3", "OT4"] - - score_by_quarter = [{"Q": q, "score1": "", "score2": ""} for q in quarters] - try: - # Сначала пробуем fullScore - full_score_str = merged.get("result", {}).get("game", {}).get("fullScore", "") - if full_score_str: - full_score_list = full_score_str.split(",") - for i, score_str in enumerate(full_score_list[: len(score_by_quarter)]): - parts = score_str.split(":") - if len(parts) == 2: - score_by_quarter[i]["score1"] = parts[0] - score_by_quarter[i]["score2"] = parts[1] - logger.info("Счёт по четвертям получен из fullScore.") - - # Если нет fullScore, пробуем scoreByPeriods - elif "scoreByPeriods" in merged.get("result", {}): - periods = merged["result"]["scoreByPeriods"] - for i, score in enumerate(periods[: len(score_by_quarter)]): - score_by_quarter[i]["score1"] = str(score.get("score1", "")) - score_by_quarter[i]["score2"] = str(score.get("score2", "")) - logger.info("Счёт по четвертям получен из scoreByPeriods.") - else: - logger.debug("Нет данных по счёту, сохраняем пустые значения.") - - out_path = "scores" - atomic_write_json(score_by_quarter, out_path) - logging.info("Сохранил payload: {out_path}") - - except Exception as e: - logger.error(f"Ошибка в Scores_Quarter: {e}", exc_info=True) - +# ============================================================================ +# 6. Лайв-петля: опрос API и поток рендера +# ============================================================================ def poll_game_live( - session, + session: requests.Session, league: str, season: str, game_id: int, lang: str, game_meta: dict, stop_event: threading.Event, -): - slow_endpoints = ["game"] # "pregame-fullstats" можно добавить обратно +) -> None: + """ + Главный цикл лайва. + + Каждые ~0.2 сек: + - решаем, какие эндпоинты давно не опрашивали (live-status, box-score, play-by-play, game), + - параллельно дёргаем их через ThreadPoolExecutor, + - сохраняем результаты в static/api_*.json, + - проверяем статус матча в live-status. + + Цикл завершится, когда: + - матч закончен (по live-status), + - календарь говорит, что игра не live, + - или выставлен stop_event (например, оператор нажал Ctrl+C). + """ + slow_endpoints = ["game"] # "pregame-fullstats" можно вернуть по желанию fast_endpoints = ["live-status", "box-score", "play-by-play"] last_call = {name: 0 for name in slow_endpoints + fast_endpoints} with ThreadPoolExecutor(max_workers=5) as executor: while True: - # внешний стоп с клавиатуры / по команде + # внешний стоп: операторская остановка или завершение run_live_loop if stop_event.is_set(): logger.info(f"[POLL] stop_event set -> break live poll for game {game_id}") break now = time.time() + # решаем, какие ручки надо дёрнуть прямо сейчас to_run = [] for ep in fast_endpoints + slow_endpoints: interval = get_interval_by_name(ep) @@ -494,11 +514,14 @@ def poll_game_live( ) game_finished = False + + # собираем результаты параллельных вызовов for fut in as_completed(futures): try: ep_name, data = fut.result() last_call[ep_name] = now + # проверяем статус лайва if ep_name == "live-status": if isinstance(data, dict): st = ( @@ -508,17 +531,16 @@ def poll_game_live( ).lower() if st in ("resultconfirmed", "finished", "result"): logger.info( - f"Game {game_id} finished by live-status -> stop loop" + f"[POLL] Game {game_id} finished by live-status" ) game_finished = True except Exception as e: - logger.exception(f"poll endpoint error: {e}") + logger.exception(f"[POLL] poll endpoint error: {e}") + # страховка: календарь говорит, что матч не лайв -> выходим if not is_game_live(game_meta): - logger.info( - f"Game {game_id} no longer live by calendar meta -> stop loop" - ) + logger.info(f"[POLL] Game {game_id} no longer live by calendar meta") break if game_finished: @@ -526,124 +548,71 @@ def poll_game_live( time.sleep(0.2) - # ещё одна точка выхода даже если статус не изменился + # вторая точка выхода по stop_event после sleep if stop_event.is_set(): logger.info(f"[POLL] stop_event set after sleep -> break live poll for game {game_id}") break -def get_data_API(session, league: str, team: str, lang: str, stop_event: threading.Event): - json_seasons = fetch_api_data( - session, "seasons", host=HOST, league=league, lang=lang - ) - if not json_seasons: - logger.error("Не удалось получить список сезонов") - 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: - logger.error("Не удалось получить список матчей") - return - - today_game, last_played = get_game_id(json_calendar, team) - - if last_played and not today_game: - game_id = last_played["game"]["id"] - logger.info(f"Последний завершённый матч id={game_id}") - fetch_api_data(session, "game", host=HOST, game_id=game_id, lang=lang) - return - - if today_game: - 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) - - if is_game_live(today_game["game"]): - t = threading.Thread( - target=run_live_loop, - args=(league, season, game_id, lang, today_game["game"], stop_event), - daemon=False, - ) - t.start() - logger.info("live thread spawned, waiting for it to finish...") - - try: - t.join() - except KeyboardInterrupt: - logger.info("KeyboardInterrupt while waiting live thread -> stop_event") - stop_event.set() - 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 для другой программы (графики и т.п.). + Собирает итоговое состояние матча (merged dict) для графики/внешки. - Возвращает dict, который потом пишем в static/ui_state.json + Читает из api_*.json: + - api_game + - api_live-status + - api_box-score + - api_play-by-play + + Обогащает: + - мержит box-score в структуру команд/игроков + - добавляет plays, scoreByPeriods, fullScore, live_status + - добавляет служебные метаданные (generatedAt) + + Возвращает словарь: + { + "meta": {...}, + "result": {...} # <-- это пойдёт в game.json / 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") + + # Минимальная защита: если ничего нет, рендер всё равно не должен падать жёстко. + if not game_data or "result" not in game_data: + raise RuntimeError("build_render_state(): api_game/result отсутствует") + game_data = game_data["result"] - # базовый безопасный каркас - for index_team, team in enumerate(game_data["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 + # проставляем статистику игроков из box-score внутрь game_data["teams"] + if box_score_data and "result" in box_score_data: + for index_team, team in enumerate(game_data["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", {}) - team["total"] = box_team.get("total", {}) + game_data["scoreByPeriods"] = box_score_data["result"].get( + "scoreByPeriods", [] + ) + game_data["fullScore"] = box_score_data["result"].get("fullScore", {}) + + # плей-бай-плей и live_status + game_data["plays"] = (play_by_play_data or {}).get("result", []) + if live_status_data and "result" in live_status_data: + game_data["live_status"] = live_status_data["result"] - 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(), @@ -657,15 +626,111 @@ def build_render_state() -> dict: return merged +def render_loop(stop_event: threading.Event, out_name: str = "game") -> None: + """ + Поток рендера. + + Пока матч идёт (или пока мы не сказали стоп), крутится так: + - собрал текущее state через build_render_state() + - посчитал командную статистику (Team_Both_Stat) + - посчитал ростер/стартеров/лидеров (Json_Team_Generation) + - посчитал счёт по четвертям (Scores_Quarter) + - всё это положил в static/*.json + + Цикл выходит, когда stop_event.is_set() == True. + """ + 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") + Scores_Quarter(state) + + # live_status отдельно, + общий state в .json + atomic_write_json([state["result"]["live_status"]], "live_status") + atomic_write_json(state["result"], out_name) + + except Exception as ex: + logger.exception(f"[RENDER_THREAD] error while building render state: {ex}") + + time.sleep(0.2) + + logger.info("[RENDER_THREAD] stop render loop") + + +def run_live_loop( + league: str, + season: str, + game_id: int, + lang: str, + game_meta: dict, + stop_event: threading.Event, +) -> None: + """ + Оркестратор матча. + + Делает: + - создаёт отдельную сессию для лайва, + - стартует поток рендера, + - запускает poll_game_live (опрос live API, запись api_*.json), + - ждёт, пока матч не кончится, + - по окончании поднимает stop_event, + - джойнит поток рендера. + + Это запускается в отдельном Thread из get_data_API(). + """ + logger.info( + f"[LIVE_THREAD] start live loop for game_id={game_id} " + f"(league={league}, season={season})" + ) + + # отдельная сессия только для лайва + session = create_session() + + # поток рендера (он не демон — мы хотим дождаться финальной записи JSON) + render_thread = threading.Thread( + target=render_loop, + args=(stop_event,), + daemon=False, + ) + render_thread.start() + logger.info("[LIVE_THREAD] render thread spawned") + + try: + poll_game_live( + session=session, + league=league, + season=season, + game_id=game_id, + lang=lang, + game_meta=game_meta, + stop_event=stop_event, + ) + except Exception as e: + logger.exception(f"[LIVE_THREAD] crash in live loop for game_id={game_id}: {e}") + finally: + # В любом случае (матч кончился, KeyboardInterrupt, ошибка) — + # говорим рендеру завершаться и дожидаемся. + stop_event.set() + logger.info(f"[LIVE_THREAD] stopping render thread for game_id={game_id}") + render_thread.join() + logger.info(f"[LIVE_THREAD] stop live loop for game_id={game_id}") + + +# ============================================================================ +# 7. Постобработка статистики для вывода +# ============================================================================ + def format_time(seconds: float | int) -> str: """ - Форматирует время в секундах в строку "M:SS". - - Args: - seconds (float | int): Количество секунд. - - Returns: - str: Время в формате "M:SS". + Удобный формат времени для игроков: + 71 -> "1:11" + 0 -> "0:00" + Любые кривые значения -> "0:00". """ try: total_seconds = int(float(seconds)) @@ -677,41 +742,34 @@ def format_time(seconds: float | int) -> str: def Json_Team_Generation( - merged: dict, *, out_dir: str = "static", who: str | None = None + merged: dict, + *, + who: Optional[str] = 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 + Формирует и записывает несколько JSON-файлов по составу и игрокам команды: + - .json (полный список игроков с метриками) + - topTeam1.json / topTeam2.json (топ-игроки) + - started_team1.json / started_team2.json (игроки на паркете) - # Имя файла - # print(merged) - # merged = + Вход: + merged: словарь из build_render_state() + who: "team1" или "team2" + """ if who == "team1": - for i in merged["result"]["teams"]: - if i["teamNumber"] == 1: - payload = i + payload = next( + (i for i in merged["result"]["teams"] if i["teamNumber"] == 1), None + ) 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 + payload = next( + (i for i in merged["result"]["teams"] if i["teamNumber"] == 2), None + ) + else: + return + + if not payload: + return + role_list = [ ("Center", "C"), ("Guard", "G"), @@ -722,575 +780,235 @@ def Json_Team_Generation( ("Point Guard", "PG"), ("Forward-Center", "FC"), ] - starts = payload["starts"] - team = [] + + starts = payload.get("starts", []) + team_rows = [] + for item in starts: - player = { - "id": (item["personId"] if item["personId"] else ""), - "num": item["displayNumber"], - "startRole": item["startRole"], - "role": item["positionName"], + stats = item.get("stats") or {} + # маппинг одной строки игрока + row = { + "id": item.get("personId") or "", + "num": item.get("displayNumber"), + "startRole": item.get("startRole"), + "role": item.get("positionName"), "roleShort": ( [ r[1] for r in role_list - if r[0].lower() == item["positionName"].lower() + if r[0].lower() == (item.get("positionName") or "").lower() ][0] - if any(r[0].lower() == item["positionName"].lower() for r in role_list) + if any( + r[0].lower() == (item.get("positionName") or "").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 + f"{(item.get('firstName') or '').strip()} {(item.get('lastName') or '').strip()}".strip() + if item.get("firstName") is not None + and item.get("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 + "captain": item.get("isCapitan", False), + "age": item.get("age") or 0, + "height": f"{item.get('height')} cm" if item.get("height") else 0, + "weight": f"{item.get('weight')} kg" if item.get("weight") else 0, + "isStart": stats.get("isStart", False), + "isOn": "🏀" if stats.get("isOnCourt") is True else "", + "flag": ( + "https://flagicons.lipis.dev/flags/4x3/" + + ( + "ru" + if item.get("countryId") is None + and item.get("countryName") == "Russia" + else ( + "" if item.get("countryId") is None + else (item.get("countryId") or "").lower() + if item.get("countryName") is not None + else "" + ) + ) + + ".svg" ), + "pts": stats.get("points", 0), + "pt-2": f"{stats.get('goal2',0)}/{stats.get('shot2',0)}" if stats else 0, + "pt-3": f"{stats.get('goal3',0)}/{stats.get('shot3',0)}" if stats else 0, + "pt-1": f"{stats.get('goal1',0)}/{stats.get('shot1',0)}" if stats else 0, "fg": ( - f"{item['stats']['goal2'] + item['stats']['goal3']}/{item['stats']['shot2'] + item['stats']['shot3']}" - if item["stats"] + f"{stats.get('goal2',0)+stats.get('goal3',0)}/" + f"{stats.get('shot2',0)+stats.get('shot3',0)}" + if 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, + "ast": stats.get("assist", 0), + "stl": stats.get("steal", 0), + "blk": stats.get("block", 0), + "blkVic": stats.get("blocked", 0), + "dreb": stats.get("defReb", 0), + "oreb": stats.get("offReb", 0), + "reb": stats.get("defReb", 0) + stats.get("offReb", 0), + "to": stats.get("turnover", 0), + "foul": stats.get("foul", 0), + "foulT": stats.get("foulT", 0), + "foulD": stats.get("foulD", 0), + "foulC": stats.get("foulC", 0), + "foulB": stats.get("foulB", 0), + "fouled": stats.get("foulsOn", 0), + "plusMinus": stats.get("plusMinus", 0), + "dunk": stats.get("dunk", 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 + stats.get("points", 0) + + stats.get("defReb", 0) + + stats.get("offReb", 0) + + stats.get("assist", 0) + + 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(item["stats"]["second"]) if item["stats"] else "0:00"), + "time": format_time(stats.get("second", 0)), "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 ""), + "Name1GFX": (item.get("firstName") or "").strip(), + "Name2GFX": (item.get("lastName") or "").strip(), "photoGFX": ( os.path.join( "D:\\Photos", merged["result"]["league"]["abcName"], merged["result"][who]["name"], - # LEAGUE, - # data[who], - f"{item['displayNumber']}.png", + f"{item.get('displayNumber')}.png", ) - if item["startRole"] == "Player" + if item.get("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"), + "isOnCourt": stats.get("isOnCourt", False), } - 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) + team_rows.append(row) + + # добиваем до 12 строк, чтобы UI был ровный + count_player = sum(1 for x in team_rows if x["startRole"] == "Player") + if count_player < 12 and team_rows: + filler_count = (4 if count_player <= 4 else 12) - count_player + template_keys = list(team_rows[0].keys()) + + for _ in range(filler_count): + empty_row = {} + for key in template_keys: + if key in ["captain", "isStart", "isOnCourt"]: + empty_row[key] = False + elif key in [ + "id", + "pts", + "weight", + "height", + "age", + "ast", + "stl", + "blk", + "blkVic", + "dreb", + "oreb", + "reb", + "to", + "foul", + "foulT", + "foulD", + "foulC", + "foulB", + "fouled", + "plusMinus", + "dunk", + "kpi", + ]: + empty_row[key] = 0 + else: + empty_row[key] = "" + team_rows.append(empty_row) + + # сортируем игроков по типу роли: сначала "Player", потом "", потом "Coach" и т.д. role_priority = { "Player": 0, "": 1, "Coach": 2, "Team": 3, None: 4, - "Other": 5, # на случай неизвестных + "Other": 5, } - # print(team) sorted_team = sorted( - team, - key=lambda x: role_priority.get( - x.get("startRole", 99), 99 - ), # 99 — по умолчанию + team_rows, + key=lambda x: role_priority.get(x.get("startRole", 99), 99), ) - out_path = f"{who}" - atomic_write_json(sorted_team, out_path) - logging.info("Сохранил payload: {out_path}") + # пишем полный ростер команды + atomic_write_json(sorted_team, who) + logger.info(f"Сохранил payload: {who}.json") + + # топ-игроки по очкам/подборам/ассистам и т.д. top_sorted_team = sorted( - filter(lambda x: x["startRole"] in ["Player", ""], sorted_team), + (p for p in sorted_team if p.get("startRole") in ["Player", ""]), key=lambda x: ( - x["pts"], - x["dreb"] + x["oreb"], - x["ast"], - x["stl"], - x["blk"], - x["time"], + x.get("pts", 0), + x.get("dreb", 0) + x.get("oreb", 0), + x.get("ast", 0), + x.get("stl", 0), + x.get("blk", 0), + x.get("time", "0:00"), ), 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 = f"top{who.replace('t','T')}" - atomic_write_json(top_sorted_team, out_path) - logging.info("Сохранил payload: {out_path}") + # пустые строки не должны ломать UI процентами фолов/очков + for player in top_sorted_team: + if player.get("num", "") == "": + player["pts"] = "" + player["foul"] = "" + top_name = f"top{who.replace('t', 'T')}" + atomic_write_json(top_sorted_team, top_name) + logger.info(f"Сохранил payload: {top_name}.json") + + # кто прямо сейчас на площадке started_team = sorted( - filter( - lambda x: x["startRole"] == "Player" and x["isOnCourt"] is True, - sorted_team, + ( + p + for p in sorted_team + if p.get("startRole") == "Player" and p.get("isOnCourt") is True ), - key=lambda x: int(x["num"]), - reverse=False, + key=lambda x: int(x.get("num") or 0), ) - - out_path = f"started_{who}" - atomic_write_json(started_team, out_path) - logging.info("Сохранил payload: {out_path}") + started_name = f"started_{who}" + atomic_write_json(started_team, started_name) + logger.info(f"Сохранил payload: {started_name}.json") -def time_outs_func(data_pbp: list[dict]) -> tuple[str, int, str, int]: +def time_outs_func(data_pbp: List[dict]) -> Tuple[str, int, str, int]: """ - Вычисляет количество оставшихся таймаутов для обеих команд - и формирует строку состояния. + Считает таймауты для обеих команд и формирует читабельные строки вида: + "2 Time-outs left in 2nd half" - Args: - data_pbp: Список игровых событий (play-by-play). - - Returns: - Кортеж: (строка команды 1, остаток, строка команды 2, остаток) + Возвращает: + (строка_для_команды1, остаток1, строка_для_команды2, остаток2) """ timeout1 = [] timeout2 = [] for event in data_pbp: - if event.get("play") == 23: + if event.get("play") == 23: # 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]: + 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) @@ -1301,6 +1019,7 @@ def time_outs_func(data_pbp: list[dict]) -> tuple[str, int, str, int]: elif period < 5: count = sum(1 for t in timeout_list if 3 <= t.get("period", 0) <= period) quarter = "2nd half" + # в концовке 4-й четверти лимит может ужиматься if period == 4 and sec >= 4800 and count in (0, 1): timeout_max = 2 else: @@ -1325,18 +1044,15 @@ def time_outs_func(data_pbp: list[dict]) -> tuple[str, int, str, int]: return t1_str, t1_left, t2_str, t2_left -def add_data_for_teams(new_data: list[dict]) -> tuple[float, list, float]: +def add_data_for_teams(new_data: List[dict]) -> Tuple[float, List[Any], float]: """ - Возвращает усреднённые статистики команды: - - средний возраст - - очки со старта и скамейки + их доли - - средний рост + Считает командные агрегаты: + - средний возраст + - очки со старта vs со скамейки, + их проценты + - средний рост - Args: - new_data (list[dict]): Список игроков с полями "startRole", "stats", "age", "height" - - Returns: - tuple: (avg_age: float, points: list, avg_height: float) + Возвращает кортеж: + (avg_age, [start_pts, start%, bench_pts, bench%], avg_height_cm) """ players = [item for item in new_data if item.get("startRole") == "Player"] @@ -1347,19 +1063,15 @@ def add_data_for_teams(new_data: list[dict]) -> tuple[float, list, float]: player_count = len(players) for player in players: - stats = player.get("stats") + stats = player.get("stats") or {} if stats: - is_start = stats.get("isStart") - - # Очки - if is_start is True: + if stats.get("isStart") is True: points_start += stats.get("points", 0) - elif is_start is False: + elif stats.get("isStart") 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_age += player.get("age") or 0 + total_height += player.get("height") or 0 total_points = points_start + points_bench points_start_pro = ( @@ -1379,28 +1091,23 @@ def add_data_for_teams(new_data: list[dict]) -> tuple[float, list, float]: def add_new_team_stat( data: dict, avg_age: float, - points: float, + points: List[Any], avg_height: float, timeout_str: str, - timeout_left: str, + timeout_left: int, ) -> dict: """ - Добавляет в словарь команды форматированную статистику. - Все значения приводятся к строкам. + Берёт словарь total по команде (очки, подборы, броски и т.д.), + добавляет: + - проценты попаданий + - средний возраст / рост + - очки старт / бенч + - информацию по таймаутам + и всё приводит к строкам (для UI, чтобы не ловить типы). - 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 + def safe_int(v): try: return int(v) except (ValueError, TypeError): @@ -1423,29 +1130,34 @@ def add_new_team_stat( "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), } ) - # Приводим все значения к строкам, если нужно строго для сериализации + # всё -> строки (UI не должен думать о типах) for k in data: data[k] = str(data[k]) return data +# Статическая таблица "как назвать метрику" stat_name_list = [ ("points", "Очки", "points"), ("pt-1", "Штрафные", "free throws"), @@ -1486,12 +1198,16 @@ stat_name_list = [ ] -def Team_Both_Stat(merged: dict, *, out_dir: str = "static") -> None: +def Team_Both_Stat(merged: dict) -> None: """ - Обновляет файл team_stats.json, содержащий сравнение двух команд. + Формирует сводку по двум командам и пишет её в static/team_stats.json. - Аргументы: - stop_event (threading.Event): Событие для остановки цикла. + Делает: + - считает таймауты для обеих команд, + - считает средний возраст / рост, + - считает очки старт / скамейка, + - добавляет проценты попаданий, подборы и т.д., + - мапит имена метрик на удобные подписи. """ logger.info("START making json for team statistics") @@ -1499,25 +1215,25 @@ def Team_Both_Stat(merged: dict, *, out_dir: str = "static") -> None: teams = merged["result"]["teams"] plays = merged["result"].get("plays", []) - # Разделение команд + # Разделяем команды по teamNumber 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() + return # Таймауты 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 агрегаты total_1 = add_new_team_stat( team_1["total"], avg_age_1, @@ -1535,19 +1251,17 @@ def Team_Both_Stat(merged: dict, *, out_dir: str = "static") -> None: timeout_left2, ) - # Финальный JSON + # Готовим список пар "метрика -> команда1 vs команда2" 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] + val1 = total_1[key] + val2 = total_2[key] + + stat_rus = "" + stat_eng = "" + for metric_name, rus, eng in stat_name_list: + if metric_name == key: + stat_rus, stat_eng = rus, eng break result_json.append( @@ -1560,43 +1274,154 @@ def Team_Both_Stat(merged: dict, *, out_dir: str = "static") -> None: } ) - out_path = "team_stats" - atomic_write_json(result_json, out_path) - logging.info("Сохранил payload: {out_path}") + atomic_write_json(result_json, "team_stats") + logger.info("Сохранил payload: team_stats.json") - 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 = "game"): +def Scores_Quarter(merged: dict) -> None: """ - Крутится в отдельном потоке. - Читает api_*.json, собирает финальный state - и сохраняет в static/.json. - Работает, пока stop_event не установлен. + Пишет счёт по четвертям и овертаймам в static/scores.json. + + Логика: + - если есть game.result.game.fullScore -> парсим "XX:YY,AA:BB,..." + - иначе используем scoreByPeriods из box-score """ - logger.info("[RENDER_THREAD] start render loop") + logger.info("START making json for scores quarter") - 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") - Scores_Quarter(state) - atomic_write_json([state["result"]["live_status"]], "live_status") - atomic_write_json(state["result"], out_name) + quarters = ["Q1", "Q2", "Q3", "Q4", "OT1", "OT2", "OT3", "OT4"] + score_by_quarter = [{"Q": q, "score1": "", "score2": ""} for q in quarters] - except Exception as ex: - logger.exception(f"[RENDER_THREAD] error while building render state: {ex}") + try: + full_score_str = merged.get("result", {}).get("game", {}).get("fullScore", "") - time.sleep(0.2) + if full_score_str: + # пример: "19:15,20:22,18:18,25:10" + full_score_list = full_score_str.split(",") + for i, score_str in enumerate(full_score_list[: len(score_by_quarter)]): + parts = score_str.split(":") + if len(parts) == 2: + score_by_quarter[i]["score1"] = parts[0] + score_by_quarter[i]["score2"] = parts[1] + logger.info("Счёт по четвертям получен из fullScore.") + elif "scoreByPeriods" in merged.get("result", {}): + periods = merged["result"]["scoreByPeriods"] + for i, score in enumerate(periods[: len(score_by_quarter)]): + score_by_quarter[i]["score1"] = str(score.get("score1", "")) + score_by_quarter[i]["score2"] = str(score.get("score2", "")) + logger.info("Счёт по четвертям получен из scoreByPeriods.") + else: + logger.debug("Нет данных по счёту, сохраняем пустые значения.") - logger.info("[RENDER_THREAD] stop render loop") + atomic_write_json(score_by_quarter, "scores") + logger.info("Сохранил payload: scores.json") + + except Exception as e: + logger.error(f"Ошибка в Scores_Quarter: {e}", exc_info=True) -def main(): +# ============================================================================ +# 8. Суточный цикл: находим игру, следим в лайве, потом уходим спать +# ============================================================================ + +def get_data_API( + session: requests.Session, + league: str, + team: str, + lang: str, + stop_event: threading.Event, +) -> None: + """ + Один "дневной прогон" логики: + 1. Узнать текущий сезон + 2. Обновить standings и calendar + 3. Найти игру для нашей команды сегодня (today_game) или последнюю законченную (last_played) + 4. Если есть last_played и нет игры сегодня -> просто забираем /game и пишем api_game.json + 5. Если есть игра сегодня: + - пишем /game сессии + - если статус игры live -> запускаем run_live_loop() в отдельном потоке + и ждём его завершения (до конца матча) + 6. Если нет ничего -> просто логируем и выходим + + Эта функция НЕ усыпляет процесс — цикл сна делает main(). + """ + # 1. сезоны + json_seasons = fetch_api_data( + session, "seasons", host=HOST, league=league, lang=lang + ) + if not json_seasons: + logger.error("Не удалось получить список сезонов") + return + + season = json_seasons[0]["season"] + + # 2. standings и calendar + 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: + logger.error("Не удалось получить список матчей") + return + + # 3. какая игра нас интересует? + today_game, last_played = get_game_id(json_calendar, team) + + # 4. уже сыграли, но сегодня не играем -> просто пишем /game последнего матча + if last_played and not today_game: + game_id = last_played["game"]["id"] + logger.info(f"Последний завершённый матч id={game_id}") + fetch_api_data(session, "game", host=HOST, game_id=game_id, lang=lang) + return + + # 5. матч сегодня + if today_game: + game_id = today_game["game"]["id"] + logger.info(f"Онлайн матч id={game_id}") + + # всегда пишем /game прямо сейчас (обновим api_game.json) + fetch_api_data(session, "game", host=HOST, game_id=game_id, lang=lang) + + # если матч идёт — стартуем live + if is_game_live(today_game["game"]): + t = threading.Thread( + target=run_live_loop, + args=(league, season, game_id, lang, today_game["game"], stop_event), + daemon=False, + ) + t.start() + logger.info("live thread spawned, waiting for it to finish...") + + try: + t.join() + except KeyboardInterrupt: + logger.info("KeyboardInterrupt while waiting live thread -> stop_event") + stop_event.set() + t.join() + + logger.info("live thread finished") + + return + + # 6. ничего подходящего + logger.info("Для этой команды игр сегодня нет и нет завершённой последней игры.") + + +def main() -> None: + """ + Главный цикл демона. + + Работает бесконечно: + - собирает данные на сегодня (get_data_API) + - если нужно, следит за матчем в реальном времени до свистка + - после этого уходит спать до 00:05 следующего дня по APP_TZ + - повторяет + + Ctrl+C: + - моментально поднимает stop_event и завершает работу. + """ parser = argparse.ArgumentParser() parser.add_argument("--league", required=True) parser.add_argument("--team", required=True) @@ -1604,6 +1429,7 @@ def main(): args = parser.parse_args() while True: + # на каждый "прогон дня" — своя HTTP-сессия и свой stop_event session = create_session() stop_event = threading.Event() @@ -1614,15 +1440,17 @@ def main(): stop_event.set() break except Exception as e: + # мы не падаем навсегда, а логируем, чтобы демон продолжил жить logger.exception(f"main loop crash: {e}") - # Спим до 00:05 следующего дня + # === сон до завтрашних 00:05 по APP_TZ === now = datetime.now(APP_TZ) tomorrow = (now + timedelta(days=1)).replace( hour=0, minute=5, second=0, microsecond=0 ) sleep_seconds = (tomorrow - now).total_seconds() if sleep_seconds < 0: + # защита, если вдруг текущее время уже после 00:05 и replace() дал прошедшее tomorrow = (now + timedelta(days=2)).replace( hour=0, minute=5, second=0, microsecond=0 ) @@ -1639,15 +1467,13 @@ def main(): logger.info("KeyboardInterrupt во время сна -> выходим.") break - # и снова в бой — новый день - stop_event = threading.Event() - session = create_session() - - -if __name__ == "__main__": - main() - + # идём на новую итерацию while True + # (новая сессия / новый stop_event создаются в начале цикла) +# ============================================================================ +# 9. Точка входа +# ============================================================================ + if __name__ == "__main__": main()