From a9605be5203c434b0417da7136fe205f387a74d6 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: Wed, 29 Oct 2025 13:24:31 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D0=BC=D0=B5=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=84=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2=20get=5Fdata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- get_data.py | 3171 +++++++++++++++++++++++----------------------- get_data_new.py | 2138 ------------------------------- get_data_old2.py | 2113 ++++++++++++++++++++++++++++++ 3 files changed, 3711 insertions(+), 3711 deletions(-) delete mode 100644 get_data_new.py create mode 100644 get_data_old2.py diff --git a/get_data.py b/get_data.py index 412eeeb..bc0bdfc 100644 --- a/get_data.py +++ b/get_data.py @@ -1,98 +1,99 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -""" -Шаблон: мониторинг онлайн-матча по лиге/команде -Функции: -1) Валидация тега (--league), иначе: лог + сообщение в Telegram и завершение. -2) Получение номера последнего сезона из JSON по ссылке. Ошибки -> лог + Telegram. -3) Загрузка расписания по ссылке, поиск игры на сегодня для --team. - - Если сегодня нет матча — берём последний сыгранный (если есть), иначе лог + Telegram. - - Если сегодня есть игра — запускаем поток-монитор: - * статус «онлайн?» проверяем РАЗ В МИНУТУ; - * при статусе онлайн — КАЖДУЮ СЕКУНДУ дергаем три запроса: - box-score, play-by-play, live-status. - * если не онлайн — ждём минуту до следующей проверки. -4) Ежедневная перекладка: каждый следующий день повторно проверяем расписание - для команды. Если матча нет — один раз подгружаем последний сыгранный и ждём следующего дня. - -ЗАМЕТКИ: -- Заполни URL_* и функции-экстракторы JSON под свой формат. -- Для Telegram используй переменные окружения TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID. -""" - -from __future__ import annotations -import os -import sys import time +import os, tempfile import json +import tempfile import argparse -import logging -import pandas as pd -import logging.config -import threading -import concurrent.futures -import queue import platform -from requests.adapters import HTTPAdapter -from urllib3.util.retry import Retry +import logging +import logging.config from datetime import datetime, timedelta, timezone from zoneinfo import ZoneInfo -from typing import Any, Dict, List -import tempfile +from typing import Any, Dict, List, Tuple, Optional from pathlib import Path -from threading import Event, Lock +import pandas as pd +import numpy as np 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://deti.russiabasket.org" + +# Таймзона, в которой мы считаем время матчей / расписания / сна до завтра APP_TZ = ZoneInfo("Europe/Moscow") -# Разрешённые теги лиг -ALLOWED_LEAGUES = { - "vtb", # Единая Лига ВТБ - "vtbyouth", # Молодежка ВТБ - "vtb-supercup", # Супер-Кубок ЕЛ ВТБ - "msl", # Высшая лига. Мужчины - "mcup", # Кубок России. Мужчины - "wpremier", # Премьер-Лига. Женщины - "wsl", # Суперлига. Женщины - "whl", # Высшая лига. Женщины - "wcup", # Кубок России. Женщины - "dubl-b", # Дюбл до 19 лет - # "pr-mezhreg-w13", # Межрегиональные соревнования до 14 лет - # добавляй свои… -} - -DEFAULT_LEAGUE = "vtb" -DEFAULT_LANG = "en" - -# URL-шаблоны (замени на реальные) -HOST = "ref.russiabasket.org" -URL_SEASON = "https://{host}/api/abc/comps/seasons?Tag={league}&Lang={lang}" # вернёт JSON со списком сезонов -URL_SCHEDULE = "https://{host}/api/abc/comps/calendar?Tag={league}&Season={season}&Lang={lang}&MaxResultCount=1000" # расписание лиги (или команды) -# Статус конкретной игры (используется для проверки "онлайн?" раз в минуту) -URL_GAME = "https://{host}/api/abc/games/game?Id={game_id}&lang={lang}" -# Быстрые запросы, когда матч онлайн (каждую секунду) -URL_BOX_SCORE = "https://{host}/api/abc/games/box-score?Id={game_id}&lang={lang}" -URL_PLAY_BY_PLAY = "https://{host}/api/abc/games/play-by-play?Id={game_id}&lang={lang}" -URL_LIVE_STATUS = "https://{host}/api/abc/games/live-status?Id={game_id}&lang={lang}" -URL_STANDINGS = "https://{host}/api/abc/comps/actual-standings?tag={league}&season={season}&lang={lang}" - -# Интервалы опроса -STATUS_CHECK_INTERVAL_SEC = 60 # проверять "онлайн?" раз в минуту -ONLINE_FETCH_INTERVAL_SEC = 1 # когда матч онлайн, дергать три запроса каждую секунду -POLL_INTERVAL_OFFLINE_SEC = 300 # резервный интервал сна при ошибках/до старта -TIMEOUT_DATA_OFF = 600 +MYHOST = platform.node() TELEGRAM_BOT_TOKEN = "7639240596:AAH0YtdQoWZSC-_R_EW4wKAHHNLIA0F_ARY" # TELEGRAM_CHAT_ID = 228977654 TELEGRAM_CHAT_ID = -4803699526 -MYHOST = platform.node() +# Глобальный лок для потокобезопасной записи JSON +_write_lock_api = threading.Lock() +_write_lock_out = threading.Lock() +_pregame_done_for_game = {} + +# Карта всех ручек API, с интервалами опроса в секундах. +URLS = [ + { + "name": "seasons", + "url": "{host}/api/abc/comps/seasons?Tag={league}&Lang={lang}", + "interval": 86400, # раз в сутки + }, + { + "name": "standings", + "url": "{host}/api/abc/comps/standings?tag={league}&season={season}&lang={lang}", + "interval": 1800, # раз в 30 минут + }, + { + "name": "calendar", + "url": "{host}/api/abc/comps/calendar?Tag={league}&Season={season}&Lang={lang}&MaxResultCount=1000", + "interval": 86400, # раз в сутки + }, + { + "name": "game", + "url": "{host}/api/abc/games/game?Id={game_id}&Lang={lang}", + "interval": 60, # раз в 10 минут + }, + { + "name": "pregame", + "url": "{host}/api/abc/games/pregame?tag={league}&season={season}&Id={game_id}&Lang={lang}", + "interval": 86400, # раз в сутки + }, + { + "name": "pregame-fullstats", + "url": "{host}/api/abc/games/pregame-fullstats?tag={league}&season={season}&id={game_id}&lang={lang}", + "interval": 600, # раз в 10 минут + }, + { + "name": "live-status", + "url": "{host}/api/abc/games/live-status?id={game_id}", + "interval": 1, # каждую секунду + }, + { + "name": "box-score", + "url": "{host}/api/abc/games/box-score?Id={game_id}", + "interval": 1, # каждую секунду + }, + { + "name": "play-by-play", + "url": "{host}/api/abc/games/play-by-play?Id={game_id}", + "interval": 1, # каждую секунду + }, +] + + +# ============================================================================ +# 2. Логирование +# ============================================================================ + if not os.path.exists("logs"): os.makedirs("logs") @@ -142,83 +143,249 @@ logger = logging.getLogger(__name__) logger.handlers[2].formatter.use_emoji = True -# ========================== -# ---- УТИЛИТЫ -# ========================== +# ============================================================================ +# 3. I/O вспомогательные функции +# ============================================================================ -def fetch_json( - url: str, params: dict | None = None, session: requests.Session | None = None -) -> dict: +def _select_lock(path: str): + filename = os.path.basename(path) + # все сырые файлы мы называем api_*.json (api_game.json, api_box-score.json, ...) + if filename.startswith("api_"): + return _write_lock_api + return _write_lock_out + +def atomic_write_json(path: str, data: Any) -> None: """ - GET JSON с таймаутом и внятными ошибками. - Использует переданный session для keep-alive. + Безопасно записывает JSON: + 1. Сериализуем в память (без локов). + 2. Под коротким локом - пишем tmp и делаем os.replace(). """ - sess = session or requests + # 1. Готовим данные заранее + # ensure_ascii=False чтобы не терять юникод, indent=None чтобы не раздувать файл + payload = json.dumps(data, ensure_ascii=False, separators=(",", ":")) + full_path = "static/" + path + ".json" try: - r = sess.get(url, params=params, timeout=(3.0, 4.0)) # (connect, read) - r.raise_for_status() - return r.json() - except requests.HTTPError as he: - raise RuntimeError(f"HTTP {he.response.status_code} для {url}") from he - except requests.RequestException as re: - raise RuntimeError(f"Сетевой сбой для {url}: {re}") from re - except json.JSONDecodeError as je: - raise RuntimeError(f"Некорректный JSON на {url}: {je}") from je + with open(full_path, "r", encoding="utf-8") as f: + if f.read() == payload: + return # ничего не поменялось -> не пишем, не fsync'им + except FileNotFoundError: + pass + target = Path(full_path) + tmp_fd, tmp_path = tempfile.mkstemp( + dir=target.parent, + prefix=target.name + ".tmp.", + text=True, + ) + os.close(tmp_fd) # мы будем писать сами + lock = _select_lock(full_path) + with lock: + # 2a. Записываем полностью во временный файл + with open(tmp_path, "w", encoding="utf-8") as f: + f.write(payload) + f.flush() + os.fsync(f.fileno()) -# ========================== -# ---- ЭКСТРАКТОРЫ ИЗ JSON -# ========================== + # 2b. Атомарно подменяем + os.replace(tmp_path, target) - -def extract_last_season(data: dict) -> str: +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: - seasons = data["items"] - if not seasons: - raise ValueError("пустой список сезонов") - last = seasons[0] - season_id = last["season"] - return str(season_id) - except Exception as e: - raise RuntimeError(f"Не удалось извлечь последний сезон: {e}") from e + 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 extract_team_schedule_for_season(data: dict, team_code: str) -> list[dict]: +def _now_iso() -> str: """ - Верни список игр команды. Адаптируй ключи под реальный JSON. - Предполагаем формат игр: - { - "gameId": "12345", - "home": "BOS", - "away": "LAL", - "startTimeUTC": "2025-10-23T18:00:00Z", - "status": "finished|scheduled|inprogress" - } + Возвращает текущее время в 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( + total=3, + backoff_factor=0.5, + status_forcelist=[429, 500, 502, 503, 504], + ) + + session.mount("https://", HTTPAdapter(max_retries=retries)) + session.headers.update( + { + "Connection": "keep-alive", + "Accept": "application/json, */*", + "Accept-Encoding": "gzip, deflate, br", + "User-Agent": "game-watcher/1.0", + } + ) + + return session + + +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() + atomic_write_json(f"api_{name}", data) + return data + + +def get_items(data: dict) -> Optional[list]: + """ + Мелкий хелпер: берём первый список в ответе API. + Многие ручки отдают {"result":[...]} или {"seasons":[...]}. + Если находим список — возвращаем его. + Если нет — возвращаем None (значит, нужно брать весь dict). + """ + for k, v in data.items(): + if isinstance(v, list): + return data[k] + return None + + +def fetch_api_data( + session: requests.Session, name: str, name_save: str = None, **kwargs +) -> Any: + """ + Универсальный обёртчик над API: + - строит URL по имени ручки, + - тянет данные через get_json(), + - ищет "главный" список (get_items), + - возвращает список или весь dict. + + Параллельно пишет в static/api_.json (через get_json()). + """ + url = build_url(name, **kwargs) try: - games = data["items"] - team_games = [ - g for g in games if g.get("team1").get("name").lower() == team_code.lower() - ] - return team_games - except Exception as e: - raise RuntimeError(f"Не удалось извлечь расписание команды: {e}") from e + 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) + return endpoint_name, data + + if endpoint_name == "box-score": + 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) + return endpoint_name, data + + if endpoint_name == "game": + data = fetch_api_data(session, "game", host=HOST, game_id=game_id, lang=lang) + return endpoint_name, data + + if endpoint_name == "pregame-fullstats": + data = fetch_api_data( + session, + "pregame-fullstats", + host=HOST, + league=league, + season=season, + game_id=game_id, + lang=lang, + ) + return endpoint_name, data + + # fallback — "ну вдруг добавим что-то ещё" + data = fetch_api_data(session, endpoint_name, host=HOST, game_id=game_id, lang=lang) + return endpoint_name, data + + +def get_interval_by_name(name: str) -> int: + """ + Возвращает рекомендуемый интервал опроса эндпоинта в секундах, + как задано в URLS. + """ + 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 @@ -230,7 +397,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: @@ -241,263 +408,666 @@ 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 extract_game_status(data: dict) -> str: +def get_game_id( + team_games: List[dict], + team: str, +) -> Tuple[Optional[dict], Optional[dict]]: """ - Ожидаем JSON вида {"status":"inprogress|scheduled|finished"} + Находим интересующую нас игру. + + Логика (важно): + - считаем, что интересующая нас команда — это team1 (домашняя), + и сравниваем по имени. + - если есть игра сегодня -> это today_game + - иначе берём последнюю уже завершённую игру -> last_played + - возвращаем (today_game, last_played) + + Если и того и другого нет -> (None, None). """ - try: - return str(data["result"]["status"]["id"]).lower() - except Exception as e: - raise RuntimeError(f"Не удалось извлечь статус матча: {e}") from e + now = datetime.now(APP_TZ) + today = now.date() + today_game = None + last_played = None + + 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 + and g["team1"]["name"].lower() == team.lower() + ): + today_game = g + last_played = None + + # Последняя завершённая игра (resultconfirmed) + elif ( + start <= now + and status == "resultconfirmed" + and g["team1"]["name"].lower() == team.lower() + ): + today_game = None + last_played = g + + return today_game, last_played -# ========================== -# ---- ДОП. ЗАПРОСЫ ПРИ ОНЛАЙНЕ -# ========================== - - -def fetch_box_score( - league: str, game_id: str, lang: str, session: requests.Session | None = None -) -> dict: - url = URL_BOX_SCORE.format(host=HOST, league=league, game_id=game_id, lang=lang) - return fetch_json(url, session=session) - - -def fetch_play_by_play( - league: str, game_id: str, lang: str, session: requests.Session | None = None -) -> dict: - url = URL_PLAY_BY_PLAY.format(host=HOST, league=league, game_id=game_id, lang=lang) - return fetch_json(url, session=session) - - -def fetch_live_status( - league: str, game_id: str, lang: str, session: requests.Session | None = None -) -> dict: - url = URL_LIVE_STATUS.format(host=HOST, league=league, game_id=game_id, lang=lang) - return fetch_json(url, session=session) - - -def _now_iso() -> str: - return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") - - -def _get(d: dict | None, *path, default=None): - """Безопасно достаём вложенные ключи: _get(d, "result", "fullScore", default={})""" - cur = d or {} - for p in path: - if not isinstance(cur, dict) or p not in cur: - return default - cur = cur[p] - return cur - - -def _dedup_plays(plays: List[dict]) -> List[dict]: +def is_game_live(game_obj: dict) -> bool: """ - Удаляем дубли по стабильному идентификатору события. - Приоритет: eventId -> id -> (sequence, clock, teamId, type) + Пытаемся понять, идёт ли матч прямо сейчас. + + Правила: + - 'resultconfirmed' / 'finished' / 'result' => матч уже окончен + - 'scheduled' / 'notstarted' / 'draft' => матч ещё не начался + - всё остальное считаем лайвом (в том числе 'online', 'inprogress', и т.п.) """ - seen = set() - out = [] - for ev in plays: - if not isinstance(ev, dict): - continue - key = ( - ev.get("eventId") - or ev.get("id") - or (ev.get("sequence"), ev.get("clock"), ev.get("teamId"), ev.get("type")) - ) - if key in seen: - continue - seen.add(key) - out.append(ev) - # если есть поле sequence/time — отсортируем, чтобы обработчик получал стабильный порядок - out.sort( - key=lambda e: ( - e.get("sequence") is None, - e.get("sequence"), - e.get("time") or e.get("clock"), - ) - ) - return out + status = (game_obj.get("gameStatus") or "").lower() + + if status in ("resultconfirmed", "finished", "result"): + return False + if status in ("notstarted", "draft"): + return False + return True -def merge_online_payloads( - game: dict, - box_score: dict | None, - play_by_play: dict | None, - live_status: dict | None, -) -> Dict[str, Any]: +def classify_game_state_from_status(status_raw: str) -> str: """ - Склеивает онлайн-ответы в единый компактный payload для downstream-обработки. - Ничего не знает о внутренней логике обработки — только нормализует. + Делит статус игры на три фазы: + - "finished" -> матч точно завершён + - "upcoming" -> матч ещё не начался, но он сегодня + - "live" -> матч идёт + + Используется в get_data_API(), чтобы решить, что делать дальше. """ - # исходные куски - # plays_raw: List[dict] = _get(play_by_play, "result", default=[]) or [] - # score_by_periods = _get(box_score, "result", "scoreByPeriods", default=[]) or [] - # full_score = _get(box_score, "result", "fullScore", default={}) or {} - # teams = _get(box_score, "result", "teams", default={}) or {} # если пригодится в обработчике - # players = _get(box_score, "result", "players", default=[]) or [] + status = (status_raw or "").lower() + if status in ("resultconfirmed", "finished", "result"): + return "finished" + if status in ("", "notstarted", "draft"): + return "upcoming" + # всё остальное считаем лайвом + return "live" - # box_score = _get(box_score, "result", "teams", default=[]) or [] - # fullScore = _get(box_score, "result", "fullScore", default="") or "" - # # live - # live_status = _get(live_status, "result", "live_status") - # period = _get(live_status, "result", "period") - # clock = _get(live_status, "result", "clock") - # status = _get(live_status, "result", "status") # e.g., "inprogress", "ended", "scheduled" +# ============================================================================ +# 6. Лайв-петля: опрос API и поток рендера +# ============================================================================ - # нормализация/дедуп - # plays = _dedup_plays(plays_raw) - # print(game["teams"]) - # print(box_score) - for index_team, team in enumerate(game["teams"][1:]): - box_team = box_score["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, +def poll_game_live( + session: requests.Session, + league: str, + season: str, + game_id: int, + lang: str, + game_meta: dict, + stop_event: threading.Event, +) -> None: + 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: + 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) + if now - last_call[ep] >= interval: + to_run.append(ep) + + futures = [] + if to_run: + for ep in to_run: + futures.append( + executor.submit( + poll_one_endpoint, + session, + ep, + league, + season, + game_id, + lang, + ) + ) + + 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": + # data может быть: + # {"status":"404","message":"Not found","result":None} + # или {"status":"200","result":{"gameStatus":"Online", ...}} + ls_result = ( + data.get("result") if isinstance(data, dict) else None + ) + game_status = "" + if isinstance(ls_result, dict): + game_status = (ls_result.get("gameStatus") or "").lower() + + if game_status in ("resultconfirmed", "finished", "result"): + logger.info( + f"[POLL] Game {game_id} finished by live-status" + ) + game_finished = True + + except Exception as e: + # логируем, но не роняем поток + logger.exception(f"[POLL] poll endpoint error: {e}") + + # страховка: календарь говорит, что матч не лайв -> выходим + if not is_game_live(game_meta): + logger.info(f"[POLL] Game {game_id} no longer live by calendar meta") + break + + if game_finished: + break + + time.sleep(0.2) + + if stop_event.is_set(): + logger.info( + f"[POLL] stop_event set after sleep -> break live poll for game {game_id}" + ) + break + + +def build_render_state() -> dict: + """ + Собирает итоговое состояние матча (merged dict) для графики/внешки. + Возвращает минимально возможный state, даже если часть данных ещё не доступна. + """ + + game_data_raw = 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") + + # Без api_game у нас вообще нет каркаса матча -> это критично + if not game_data_raw or "result" not in game_data_raw: + raise RuntimeError("build_render_state(): api_game/result отсутствует") + + game_data = game_data_raw["result"] + + # Защитимся от отсутствия ключевых структур + # Убедимся, что есть поля, которые ждёт остальной код + game_data.setdefault("teams", []) + game_data.setdefault("team1", {}) + game_data.setdefault("team2", {}) + game_data.setdefault("game", {}) + # plays - список событий + game_data["plays"] = (play_by_play_data or {}).get("result", []) or [] + + # live_status - текущее состояние периода/секунды + if live_status_data and isinstance(live_status_data.get("result"), dict): + game_data["live_status"] = live_status_data["result"] + else: + # дадим заготовку, чтобы Play_By_Play не падал + game_data["live_status"] = { + "period": 1, + "second": 0, + "gameStatus": game_data.get("game", {}).get("gameStatus", ""), + } + + # box-score -> статы игроков и команд + if ( + box_score_data + and isinstance(box_score_data.get("result"), dict) + and "teams" in box_score_data["result"] + and box_score_data["result"]["teams"] is not None + ): + for index_team, team in enumerate(game_data.get("teams", [])[1:]): + # box_team может отсутствовать, если индексы не совпали или сервер отдал None + box_teams_list = box_score_data["result"]["teams"] + if ( + isinstance(box_teams_list, list) + and index_team < len(box_teams_list) + and box_teams_list[index_team] is not None + ): + box_team = box_teams_list[index_team] + else: + box_team = {} + + # переносим статы игроков + for player in team.get("starts", []): + stat = None + if isinstance(box_team.get("starts"), list): + stat = next( + ( + s + for s in box_team["starts"] + if s.get("startNum") == player.get("startNum") + ), + None, + ) + if stat: + player["stats"] = stat + + # total по команде + team["total"] = ( + box_team.get("total", {}) if isinstance(box_team, dict) else {} ) - if stat: - player["stats"] = stat - team["total"] = box_team.get("total", {}) + # периоды и общий счёт + if isinstance(box_score_data["result"], dict): + game_data["scoreByPeriods"] = ( + box_score_data["result"].get("scoreByPeriods") or [] + ) + game_data["fullScore"] = box_score_data["result"].get("fullScore") or {} + else: + # если box-score нет ещё: + game_data.setdefault("scoreByPeriods", []) + game_data.setdefault("fullScore", {}) + # а ещё надо, чтобы у каждой команды было хотя бы .total = {} + for team in game_data.get("teams", []): + team.setdefault("total", {}) + for starter in team.get("starts", []): + starter.setdefault("stats", {}) - game["plays"] = play_by_play.get("result", []) - game["scoreByPeriods"] = box_score["result"].get("scoreByPeriods", []) - game["fullScore"] = box_score["result"].get("fullScore", {}) - game["live_status"] = live_status["result"] - # game[""] merged: Dict[str, Any] = { "meta": { "generatedAt": _now_iso(), "sourceHints": { - "boxScoreHas": list((_get(box_score, "result") or {}).keys()), - "pbpLen": "", + "boxScoreHas": "yes" if box_score_data else "no", + "pbpLen": str(len(game_data["plays"])), }, }, - "result": game, + "result": game_data, } return merged -# где-то в твоём коде -def process_online_update(merged: dict) -> None: - """ - Здесь — любая твоя логика: обновить JSON-файлы, пересчитать агрегаты, - уведомить подписчиков, обновить кеш и т.д. - """ - # пример: - game_id = merged["meta"]["gameId"] - print(game_id) - # ... твоя обработка ... +def render_loop(stop_event: threading.Event, out_name: str = "game") -> None: + logger.info("[RENDER_THREAD] start render loop") + + with ThreadPoolExecutor(max_workers=6) as pool: + while not stop_event.is_set(): + try: + try: + state = build_render_state() + except Exception as build_err: + logger.debug(f"[RENDER_THREAD] build_render_state not ready: {build_err}") + time.sleep(0.2) + continue + + tasks = { + "team_stats": pool.submit(Team_Both_Stat, state), + "team1": pool.submit(Json_Team_Generation, state, who="team1"), + "team2": pool.submit(Json_Team_Generation, state, who="team2"), + "scores": pool.submit(Scores_Quarter, state), + "referee": pool.submit(Referee, state), + "pbp": pool.submit(Play_By_Play, state), + } + + # аккуратно собрать исключения, но не умереть целиком + for name, fut in tasks.items(): + try: + fut.result() + except Exception as e: + logger.debug(f"[RENDER_THREAD] skip {name}: {e}") + + # остальное можно сделать синхронно, это быстро + try: + live_status_to_write = [] + rs = state.get("result", {}) + if isinstance(rs, dict) and "live_status" in rs: + live_status_to_write = [rs["live_status"]] + atomic_write_json("live_status", live_status_to_write) + except Exception as e: + logger.debug(f"[RENDER_THREAD] skip live_status write: {e}") + + try: + atomic_write_json(out_name, state.get("result", {})) + except Exception as e: + logger.debug(f"[RENDER_THREAD] skip {out_name}.json write: {e}") + + except Exception as ex: + logger.exception(f"[RENDER_THREAD] unexpected error: {ex}") + + time.sleep(1) + + logger.info("[RENDER_THREAD] stop render loop") -def is_already_merged(obj: dict) -> bool: - """ - Проверяем, что объект уже содержит result.plays/fullScore/scoreByPeriods. - Подходит для ответа game (исторический матч). - """ - if not isinstance(obj, dict): - return False - res = obj.get("result") or obj.get("game") or obj # подстрахуемся под разные корни - if not isinstance(res, dict): - return False - r = res.get("result", res) # иногда внутри ещё один "result" - return ( - isinstance(r, dict) - and isinstance(r.get("plays", []), list) - and isinstance(r.get("fullScore", {}), dict) - and isinstance(r.get("scoreByPeriods", []), list) +def run_live_loop( + league: str, + season: str, + game_id: int, + lang: str, + game_meta: dict, + stop_event: threading.Event, +): + logger.info( + f"[LIVE_THREAD] start live loop for game_id={game_id} (league={league}, season={season})" ) + session = create_session() -def ensure_merged_payload( - game_or_merged: dict | None = None, - *, - box_score: dict | None = None, - play_by_play: dict | None = None, - live_status: dict | None = None, - game_meta: dict | None = None, # например {"id": ..., "league": ...} -) -> dict: - """ - 1) Если передан уже-склеенный payload (исторический матч) — нормализуем и возвращаем. - 2) Иначе склеиваем из box/pbp/live через merge_online_payloads. - """ - # 1) Уже склеено (история) — просто привести к твоему контракту {meta, ids, result} - if game_or_merged and is_already_merged(game_or_merged): - g = game_or_merged.get("result") or game_or_merged # допускаем разные корни - # print(g) - with open("temp.json", "w", encoding="utf-8") as f: - json.dump(g, f, ensure_ascii=False, indent=2) - merged = { - "meta": { - "generatedAt": _now_iso(), - "sourceHints": {"from": "game_api", "pbpLen": len(g.get("plays", []))}, - }, - "result": g, - } - return merged - - - # 2) Онлайн-ветка — склеиваем так, как у тебя уже реализовано - if box_score is not None or play_by_play is not None or live_status is not None: - base_game = game_meta or {} - out_path = Path("static") / "game.json" - with open(out_path, "r", encoding="utf-8") as file: - game = json.load(file) - base_game = game["result"] - - # print(base_game) - return merge_online_payloads(base_game, box_score, play_by_play, live_status) - # 2b) Fallback: если пришёл "game", но без plays/fullScore/scoreByPeriods — всё равно сохраним - if game_or_merged: - g = game_or_merged.get("result") or game_or_merged - return { - "meta": { - "generatedAt": _now_iso(), - "sourceHints": {"from": "game_api_raw"}, - }, - "result": g, # положим сырой ответ целиком — чтобы файл гарантированно записался - } - raise ValueError( - "ensure_merged_payload: не передан ни уже-склеенный game, ни box/pbp/live." + # поток рендера + 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: + stop_event.set() + logger.info(f"[LIVE_THREAD] stopping worker threads for game_id={game_id}") + + render_thread.join() + + logger.info(f"[LIVE_THREAD] stop live loop for game_id={game_id}") -def atomic_write_json(path: str | Path, data: dict, ensure_dirs: bool = True) -> None: - path = Path(path) - if ensure_dirs: - path.parent.mkdir(parents=True, exist_ok=True) - # атомарная запись: пишем во временный файл и переименовываем - with tempfile.NamedTemporaryFile( - "w", delete=False, dir=str(path.parent), encoding="utf-8" - ) as tmp: - json.dump(data, tmp, ensure_ascii=False, indent=2) - tmp.flush() - os.fsync(tmp.fileno()) - tmp_name = tmp.name - os.replace(tmp_name, path) +def Referee(merged: dict, *, out_dir: str = "static") -> None: + """ + Поток, создающий JSON-файл с информацией о судьях матча. + """ + logger.info("START making json for referee") + + desired_order = [ + "Crew chief", + "Referee 1", + "Referee 2", + "Commissioner", + "Ст.судья", + "Судья 1", + "Судья 2", + "Комиссар", + ] + + try: + # Найти судей (teamNumber == 0) + team_ref = next( + (t for t in merged["result"]["teams"] if t["teamNumber"] == 0), None + ) + if not team_ref: + logger.warning("Не найдена судейская бригада в данных.") + + referees_raw = team_ref.get("starts", []) + referees = [] + + for r in referees_raw: + flag_code = r.get("countryId", "").lower() if r.get("countryName") else "" + referees.append( + { + "displayNumber": r.get("displayNumber", ""), + "positionName": r.get("positionName", ""), + "lastNameGFX": f"{r.get('firstName', '')} {r.get('lastName', '')}".strip(), + "secondName": r.get("secondName", ""), + "birthday": r.get("birthday", ""), + "age": r.get("age", 0), + "flag": f"https://flagicons.lipis.dev/flags/4x3/{flag_code}.svg", + } + ) + + # Сортировка по позиции + referees = sorted( + referees, + key=lambda x: ( + desired_order.index(x["positionName"]) + if x["positionName"] in desired_order + else len(desired_order) + ), + ) + out_path = "referee" + atomic_write_json(out_path, referees) + logging.info("Сохранил payload: {out_path}") + + except Exception as e: + logger.error(f"Ошибка в Referee потоке: {e}", exc_info=True) + + +def Play_By_Play(data: dict) -> None: + """ + Поток, обновляющий JSON-файл с последовательностью бросков в матче. + """ + logger.info("START making json for play-by-play") + + try: + game_data = data["result"] if "result" in data else data + + if not game_data: + logger.debug("game_online_data отсутствует") + return + + teams = game_data["teams"] + team1_data = next((i for i in teams if i.get("teamNumber") == 1), None) + team2_data = next((i for i in teams if i.get("teamNumber") == 2), None) + + if not team1_data or not team2_data: + logger.warning("Не удалось получить команды из game_online_data") + return + team1_name = game_data["team1"]["name"] + team2_name = game_data["team2"]["name"] + team1_startnum = [ + p["startNum"] + for p in team1_data.get("starts", []) + if p.get("startRole") == "Player" + ] + team2_startnum = [ + p["startNum"] + for p in team2_data.get("starts", []) + if p.get("startRole") == "Player" + ] + + plays = game_data.get("plays", []) + if not plays: + logger.debug("нет данных в play-by-play") + return + + # Получение текущего времени игры + json_live_status = ( + data["result"]["live_status"] + if "result" in data and "live_status" in data["result"] + else None + ) + last_event = plays[-1] + + if json_live_status is None: + period = last_event.get("period", 1) + second = 0 + else: + period = (json_live_status or {}).get("result", {}).get("period", 1) + second = (json_live_status or {}).get("result", {}).get("second", 0) + + # Создание DataFrame из событий + df = pd.DataFrame(plays[::-1]) + + df_goals = df[df["play"].isin([1, 2, 3])].copy() + if df_goals.empty: + logger.debug("нет данных о голах в play-by-play") + return + + # Расчёты по очкам и времени + df_goals["score1"] = ( + df_goals["startNum"].isin(team1_startnum) * df_goals["play"] + ) + df_goals["score2"] = ( + df_goals["startNum"].isin(team2_startnum) * df_goals["play"] + ) + + df_goals["score_sum1"] = df_goals["score1"].fillna(0).cumsum() + df_goals["score_sum2"] = df_goals["score2"].fillna(0).cumsum() + + df_goals["new_sec"] = ( + pd.to_numeric(df_goals["sec"], errors="coerce").fillna(0).astype(int) // 10 + ) + df_goals["time_now"] = (600 if period < 5 else 300) - second + df_goals["quar"] = period - df_goals["period"] + + df_goals["diff_time"] = np.where( + df_goals["quar"] == 0, + df_goals["time_now"] - df_goals["new_sec"], + (600 * df_goals["quar"] - df_goals["new_sec"]) + df_goals["time_now"], + ) + + df_goals["diff_time_str"] = df_goals["diff_time"].apply( + lambda x: (f"{x // 60}:{str(x % 60).zfill(2)}" if isinstance(x, int) else x) + ) + + # Текстовые поля + def generate_text(row, with_time=False, is_rus=False): + s1, s2 = int(row["score_sum1"]), int(row["score_sum2"]) + team = ( + team1_name + if not pd.isna(row["score1"]) and row["score1"] != 0 + else team2_name + ) + + # правильный порядок счёта в зависимости от команды + if team == team1_name: + score = f"{s1}-{s2}" + else: + score = f"{s2}-{s1}" + + time_str = ( + f" за {row['diff_time_str']}" + if is_rus + else f" in last {row['diff_time_str']}" + ) + prefix = "рывок" if is_rus else "run" + + return f"{team} {score} {prefix}{time_str if with_time else ''}" + + df_goals["text_rus"] = df_goals.apply( + lambda r: generate_text(r, is_rus=True, with_time=False), axis=1 + ) + df_goals["text_time_rus"] = df_goals.apply( + lambda r: generate_text(r, is_rus=True, with_time=True), axis=1 + ) + df_goals["text"] = df_goals.apply( + lambda r: generate_text(r, is_rus=False, with_time=False), axis=1 + ) + df_goals["text_time"] = df_goals.apply( + lambda r: generate_text(r, is_rus=False, with_time=True), axis=1 + ) + + df_goals["team"] = df_goals["score1"].apply( + lambda x: team1_name if not pd.isna(x) and x != 0 else team2_name + ) + + # Удаление лишнего + drop_cols = [ + "children", + "start", + "stop", + "hl", + "sort", + "startNum", + "zone", + "x", + "y", + ] + df_goals.drop(columns=drop_cols, inplace=True, errors="ignore") + + # Порядок колонок + main_cols = ["text", "text_time"] + all_cols = main_cols + [col for col in df_goals.columns if col not in main_cols] + df_goals = df_goals[all_cols] + + # Сохранение JSON + directory = "static" + os.makedirs(directory, exist_ok=True) + filepath = os.path.join(directory, "play_by_play.json") + + df_goals.to_json(filepath, orient="records", force_ascii=False) + logger.debug("Успешно положил данные об play-by-play в файл") + except Exception as e: + logger.error(f"Ошибка в Play_By_Play: {e}", exc_info=True) + + +def render_once_after_game( + session: requests.Session, + league: str, + season: str, + game_id: int, + lang: str, + out_name: str = "game", +) -> None: + """ + Одноразовая генерация всех выходных json-файлов (team_stats.json, + team1.json, team2.json, scores.json, live_status.json, game.json и т.д.) + без запуска вечного render_loop. + + Что делает: + - один раз тянет /game из API (по game_id) + - собирает полный state (build_render_state) + - считает статистику команд, игроков, судей и счёт по четвертям + - сохраняет все соответствующие JSON в static/ + + Используется, когда матч уже завершён (finished/resultconfirmed) + или нет лайва. + """ + try: + logger.info(f"[RENDER_ONCE] Fetching final game snapshot for game_id={game_id}") + # один запрос к API (ручка "game") + state = fetch_api_data( + session, + "game", + host=HOST, + game_id=game_id, + lang=lang, + ) + + Team_Both_Stat(state) + Json_Team_Generation(state, who="team1") + Json_Team_Generation(state, who="team2") + Scores_Quarter(state) + Referee(state) + Play_By_Play(state) + + atomic_write_json(out_name, state["result"]) + + logger.info("[RENDER_ONCE] финальные json сохранены успешно") + + except Exception as ex: + logger.exception(f"[RENDER_ONCE] error while building final state: {ex}") + + +# ============================================================================ +# 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)) @@ -509,41 +1079,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"), @@ -554,575 +1117,237 @@ 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 = Path(out_dir) / f"{who}.json" - atomic_write_json(out_path, sorted_team) - logging.info("Сохранил payload: {out_path}") + # пишем полный ростер команды + atomic_write_json(who, sorted_team) + 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 = Path(out_dir) / f"top{who.replace('t','T')}.json" - atomic_write_json(out_path, top_sorted_team) - 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_name, top_sorted_team) + 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 = Path(out_dir) / f"started_{who}.json" - atomic_write_json(out_path, started_team) - logging.info("Сохранил payload: {out_path}") + started_name = f"started_{who}" + atomic_write_json(started_name, started_team) + 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) @@ -1157,18 +1382,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"] @@ -1179,19 +1401,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 = ( @@ -1211,28 +1429,24 @@ 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): @@ -1271,7 +1485,6 @@ def add_new_team_stat( } ) - # Приводим все значения к строкам, если нужно строго для сериализации for k in data: data[k] = str(data[k]) @@ -1318,12 +1531,9 @@ stat_name_list = [ ] -def Team_Both_Stat(merged: dict, *, out_dir: str = "static") -> None: +def Team_Both_Stat(merged: dict) -> None: """ - Обновляет файл team_stats.json, содержащий сравнение двух команд. - - Аргументы: - stop_event (threading.Event): Событие для остановки цикла. + Формирует сводку по двум командам и пишет её в static/team_stats.json. """ logger.info("START making json for team statistics") @@ -1331,25 +1541,21 @@ def Team_Both_Stat(merged: dict, *, out_dir: str = "static") -> None: 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() + 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_1 = add_new_team_stat( team_1["total"], avg_age_1, @@ -1367,19 +1573,16 @@ def Team_Both_Stat(merged: dict, *, out_dir: str = "static") -> None: 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] + 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( @@ -1392,87 +1595,120 @@ def Team_Both_Stat(merged: dict, *, out_dir: str = "static") -> None: } ) - out_path = Path(out_dir) / "team_stats.json" - atomic_write_json(out_path, result_json) - logging.info("Сохранил payload: {out_path}") + atomic_write_json("team_stats", result_json) + logger.info("Сохранил payload: team_stats.json") - logger.debug("Успешно записаны данные в team_stats.json") except Exception as e: logger.error(f"Ошибка при обработке командной статистики: {e}", exc_info=True) -def Referee(merged: dict, *, out_dir: str = "static") -> None: - """ - Поток, создающий JSON-файл с информацией о судьях матча. - """ - logger.info("START making json for referee") - - desired_order = [ - "Crew chief", - "Referee 1", - "Referee 2", - "Commissioner", - "Ст.судья", - "Судья 1", - "Судья 2", - "Комиссар", - ] - - try: - # Найти судей (teamNumber == 0) - team_ref = next( - (t for t in merged["result"]["teams"] if t["teamNumber"] == 0), None - ) - if not team_ref: - logger.warning("Не найдена судейская бригада в данных.") - - referees_raw = team_ref.get("starts", []) - # print(referees_raw) - referees = [] - - for r in referees_raw: - flag_code = r.get("countryId", "").lower() if r.get("countryName") else "" - referees.append( - { - "displayNumber": r.get("displayNumber", ""), - "positionName": r.get("positionName", ""), - "lastNameGFX": f"{r.get('firstName', '')} {r.get('lastName', '')}".strip(), - "secondName": r.get("secondName", ""), - "birthday": r.get("birthday", ""), - "age": r.get("age", 0), - "flag": f"https://flagicons.lipis.dev/flags/4x3/{flag_code}.svg", - } - ) - - # Сортировка по позиции - referees = sorted( - referees, - key=lambda x: ( - desired_order.index(x["positionName"]) - if x["positionName"] in desired_order - else len(desired_order) +def Pregame_data_json(data: dict) -> None: + teams = [] + for data_team in (data["teamStats1"], data["teamStats2"]): + temp_team = { + "team": data_team["team"]["name"], + "games": data_team["games"], + "points": round( + (data_team["totalStats"]["points"] / data_team["games"]), 1 ), - ) - out_path = Path(out_dir) / "referee.json" - atomic_write_json(out_path, referees) - logging.info("Сохранил payload: {out_path}") - - except Exception as e: - logger.error(f"Ошибка в Referee потоке: {e}", exc_info=True) + "points_2": round( + ( + data_team["totalStats"]["goal2"] + * 100 + / data_team["totalStats"]["shot2"] + ), + 1, + ), + "points_3": round( + ( + data_team["totalStats"]["goal3"] + * 100 + / data_team["totalStats"]["shot3"] + ), + 1, + ), + "points_23": round( + ( + data_team["totalStats"]["goal23"] + * 100 + / data_team["totalStats"]["shot23"] + ), + 1, + ), + "points_1": round( + ( + data_team["totalStats"]["goal1"] + * 100 + / data_team["totalStats"]["shot1"] + ), + 1, + ), + "assists": round( + (data_team["totalStats"]["assist"] / data_team["games"]), 1 + ), + "rebounds": round( + ( + ( + data_team["totalStats"]["defRebound"] + + data_team["totalStats"]["offRebound"] + ) + / data_team["games"] + ), + 1, + ), + "steals": round((data_team["totalStats"]["steal"] / data_team["games"]), 1), + "turnovers": round( + (data_team["totalStats"]["turnover"] / data_team["games"]), 1 + ), + "blocks": round( + (data_team["totalStats"]["blockShot"] / data_team["games"]), 1 + ), + "fouls": round((data_team["totalStats"]["foul"] / data_team["games"]), 1), + } + teams.append(temp_team) + atomic_write_json("team_comparison", teams) + logger.info("Сохранил payload: team_comparison.json") -def Scores_Quarter(merged: dict, *, out_dir: str = "static") -> None: +def Pregame_data(pregame_raw: dict, game_stub: dict) -> None: """ - Поток, обновляющий JSON со счётом по четвертям. + Обработка предматчевых данных (Pregame). + Вызывается один раз ДО начала матча, если матч сегодня. + + Вход: + pregame_raw -> ответ от /pregame (dict или None) + game_stub -> today_game["game"] из календаря (минимальная информация о матче) + """ + try: + out = { + "game": game_stub or {}, + "pregame": ( + pregame_raw.get("result") + if isinstance(pregame_raw, dict) + else pregame_raw + ), + "generatedAt": _now_iso(), + } + Pregame_data_json(out["pregame"]) + # сохраняем файл + # atomic_write_json(out["pregame"], "pregame") + # logger.info("Сохранил payload: pregame.json") + except Exception as e: + logger.error(f"Ошибка в Pregame_data: {e}", exc_info=True) + + +def Scores_Quarter(merged: dict) -> None: + """ + Пишет счёт по четвертям и овертаймам в static/scores.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)]): @@ -1481,8 +1717,6 @@ def Scores_Quarter(merged: dict, *, out_dir: str = "static") -> None: 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)]): @@ -1492,622 +1726,413 @@ def Scores_Quarter(merged: dict, *, out_dir: str = "static") -> None: else: logger.debug("Нет данных по счёту, сохраняем пустые значения.") - out_path = Path(out_dir) / "scores.json" - atomic_write_json(out_path, score_by_quarter) - logging.info("Сохранил payload: {out_path}") + atomic_write_json("scores", score_by_quarter) + logger.info("Сохранил payload: scores.json") except Exception as e: logger.error(f"Ошибка в Scores_Quarter: {e}", exc_info=True) -def status_online_func(merged: dict, *, out_dir: str = "static") -> None: - """ - Получает онлайн-статус игры и возвращает данные + путь к PNG-фолам. - """ - try: - out_path = Path(out_dir) / "live_status.json" - - if "live_status" in merged["result"]: - status_data = merged["result"]["live_status"] - atomic_write_json(out_path, [status_data]) - else: - logger.warning("Матч не ОНЛАЙН!!!!") - atomic_write_json( - out_path, - [ - { - "foulsA": 0, - "foulsB": 0, - } - ], - ) - logging.info("Сохранил payload: {out_path}") - - except Exception as e: - logger.error(f"Ошибка в status_online_func: {e}", exc_info=True) - return None - - -def Standing_func(league, season, lang, stop_event: threading.Event, out_dir: str = "static") -> None: - logger.info("START making json for standings") - while not stop_event.is_set(): - try: - url = URL_STANDINGS.format(host=HOST, league=league, season=season, lang=lang) - data_standings = fetch_json(url) - - if data_standings and "items" in data_standings and data_standings["items"]: - standings_temp = data_standings["items"] - for item in standings_temp: - if "standings" in item and item["standings"] != []: - standings_temp = item["standings"] - df = pd.json_normalize(standings_temp) - del df["scores"] - if not df["totalWin"].isna().all(): - df["w_l"] = ( - df["totalWin"].astype(str) - + " / " - + df["totalDefeat"].astype(str) - ) - df["procent"] = df.apply( - lambda row: ( - 0 - if row["w_l"] == "0 / 0" - or row["totalGames"] == 0 - or pd.isna(row["totalWin"]) - else round( - row["totalWin"] * 100 / row["totalGames"] - + 0.000005 - ) - ), - axis=1, - ) - df["plus_minus"] = ( - df["totalGoalPlus"] - df["totalGoalMinus"] - ) - filepath = os.path.join( - out_dir, - f"standings_{league}_{item['comp']['name'].replace(' ', '_')}.json", - ) - - - df.to_json( - filepath, - orient="records", - force_ascii=False, - indent=4, - ) - logger.info("Standings data saved successfully.") - elif "playoffPairs" in item and item["playoffPairs"] != []: - standings_temp = item["playoffPairs"] - df = pd.json_normalize(standings_temp) - filepath = os.path.join( - out_dir, - f"standings_{league}_{item['comp']['name'].replace(' ', '_')}.json", - ) - df.to_json( - filepath, - orient="records", - force_ascii=False, - indent=4, - ) - logger.info("Standings data saved successfully.") - except Exception as e: - logger.warning(f"Ошибка в турнирном положении: {e}") - - stop_event.wait(TIMEOUT_DATA_OFF) - - - -# ========================== -# ---- ДОМЕННАЯ ЛОГИКА -# ========================== - - -def validate_league_or_die(league: str) -> str: - league = (league or DEFAULT_LEAGUE).lower().strip() - if league not in ALLOWED_LEAGUES: - logger.warning( - f"Неверный тег лиги: '{league}'. Допустимо: {sorted(ALLOWED_LEAGUES)}" - ) - sys.exit(2) - return league - - -def get_last_season_or_die(league: str, lang: str) -> str: - url = URL_SEASON.format(host=HOST, league=league, lang=lang) - try: - data = fetch_json(url) - season = extract_last_season(data) - logging.info(f"Последний сезон для {league}: {season}") - return season - except Exception as e: - logger.warning(f"Не получилось получить последний сезон для {league}: {e}") - sys.exit(3) - - -def get_team_schedule_or_die( - league: str, season: str, team: str, lang: str -) -> list[dict]: - url = URL_SCHEDULE.format(host=HOST, league=league, season=season, lang=lang) - try: - data = fetch_json(url) - team_games = extract_team_schedule_for_season(data, team) - if not team_games: - logger.warning(f"Для команды {team} не найдено игр в сезоне {season}.") - return team_games - except Exception as e: - logger.warning(f"Не получилось получить расписание {league}/{season}: {e}") - return [] - - -def pick_today_or_last_played( - team_games: list[dict], now: datetime -) -> tuple[dict | None, dict | None]: - """ - Возвращает (сегодняшняя игра, последний сыгранный матч). - """ - today = now.date() - games_sorted = sorted(team_games, key=parse_game_start_dt) - today_game = None - last_played = None - - for g in games_sorted: - start = parse_game_start_dt(g) - status = g.get("game", {}).get("gameStatus", "").lower() - if start.date() == today and today_game is None: - today_game = g - if start <= now and status == "resultconfirmed": - last_played = g - return today_game, last_played - - -def is_game_online(league: str, game_id: str, lang: str) -> str: - """ - Возвращает статус: inprogress|scheduled|finished (или то, что твой API даёт). - """ - url = URL_GAME.format(host=HOST, league=league, game_id=game_id, lang=lang) - data = fetch_json(url) - - out_path = Path("static") / "game.json" - atomic_write_json(out_path, data) - - return extract_game_status(data) - - -class PostProcessor: - def __init__(self): - self.q = queue.Queue(maxsize=1) - self._t = threading.Thread(target=self._worker, daemon=True) - self._stop = threading.Event() - self._t.start() - - def submit(self, merged): - # кладём только «последний» payload - try: - # если очередь занята, выкидываем старое задание - while True: - self.q.get_nowait() - except queue.Empty: - pass - # не блокируем: если за эту миллисекунду кто-то положил — просто заменим в следующий раз - try: - self.q.put_nowait(merged) - except queue.Full: - pass - - def _worker(self): - while not self._stop.is_set(): - merged = self.q.get() - try: - Json_Team_Generation(merged, out_dir="static", who="team1") - Json_Team_Generation(merged, out_dir="static", who="team2") - Team_Both_Stat(merged, out_dir="static") - Referee(merged, out_dir="static") - Scores_Quarter(merged, out_dir="static") - status_online_func(merged, out_dir="static") - except Exception as e: - logging.exception(f"Postproc failed: {e}") - - def stop(self): - self._stop.set() - - -class OnlinePoller: - def __init__( - self, league: str, game_id: str, lang: str, on_update: callable | None = None - ): - self.league = league - self.game_id = game_id - self.lang = lang - self._stop_event = threading.Event() - self._thread: threading.Thread | None = None - self._log = logging.info("start") - self._on_update = on_update - self._post = PostProcessor() - - # 1) Постоянная сессия и пул соединений - self._session = requests.Session() - retry = Retry( - total=2, - connect=2, - read=2, - backoff_factor=0.1, - status_forcelist=(502, 503, 504), - allowed_methods=frozenset(["GET"]), - ) - adapter = HTTPAdapter(pool_connections=1, pool_maxsize=10, max_retries=retry) - self._session.mount("http://", adapter) - self._session.mount("https://", adapter) - self._session.headers.update( - { - "Connection": "keep-alive", - "Accept": "application/json, */*", - "Accept-Encoding": "gzip, deflate, br", - "User-Agent": "game-watcher/1.0", - } - ) - - def stop(self): - if self._thread and self._thread.is_alive(): - self._stop_event.set() - self._thread.join(timeout=2) - # self._log.info(f"Онлайн-поллер для игры {self.game_id} остановлен.") - self._thread = None - try: - self._session.close() - except Exception: - pass - try: - self._post.stop() - except Exception: - pass - - def _run(self): - # Исполнитель для параллельных GET - with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool: - while not self._stop_event.is_set(): - started = time.perf_counter() - try: - futures = [ - pool.submit( - fetch_box_score, - self.league, - self.game_id, - self.lang, - self._session, - ), - pool.submit( - fetch_play_by_play, - self.league, - self.game_id, - self.lang, - self._session, - ), - pool.submit( - fetch_live_status, - self.league, - self.game_id, - self.lang, - self._session, - ), - ] - bs, pbp, ls = (f.result() for f in futures) - merged = ensure_merged_payload( - None, - box_score=bs, - play_by_play=pbp, - live_status=ls, - game_meta={"id": self.game_id, "league": self.league}, - ) - # print(merged) - # внешний коллбек, если задан - if self._on_update: - self._on_update(merged) - - # твоя общая обработка + сохранение - self._post.submit(merged) - - logger.debug( - "Обновления online: box-score(%s keys), pbp(%s keys), live-status(%s keys)", - len(bs) if isinstance(bs, dict) else "—", - len(pbp) if isinstance(pbp, dict) else "—", - len(ls) if isinstance(ls, dict) else "—", - ) - except Exception as e: - logger.warning(f"Сбой online-поллера для игры {self.game_id}: {e}") - # лёгкая задержка после ошибки, но не «наказание» на целую секунду - time.sleep(0.2) - - # Точное выдерживание частоты: «1 цикл в секунду» - elapsed = time.perf_counter() - started - rest = ONLINE_FETCH_INTERVAL_SEC - elapsed - if rest > 0: - # спим только остаток - self._stop_event.wait(rest) - - def start(self): - if self._thread and self._thread.is_alive(): - return - self._stop_event.clear() - self._thread = threading.Thread( - target=self._run, - name=f"poller-{self.game_id}", - daemon=True, - ) - self._thread.start() - self._log.info(f"Онлайн-поллер для игры {self.game_id} запущен.") - - -def monitor_game_loop( - league: str, game_id: str, lang: str, stop_event: threading.Event +def Standing_func( + session: requests.Session, + league: str, + season: str, + lang: str, + stop_event: threading.Event, + out_dir: str = "static", ) -> None: - logger.info(f"Старт мониторинга игры {game_id} ({league}).") - poller = OnlinePoller(league, game_id, lang) - was_online = False + """ + Фоновый поток с турнирной таблицей (standings). + """ + logger.info("[STANDINGS_THREAD] start standings loop") + + last_call_ts = 0 + json_seasons = fetch_api_data( + session, "seasons", host=HOST, league=league, lang=lang + ) + season = json_seasons[0]["season"] + + interval = get_interval_by_name("standings") while not stop_event.is_set(): + now = time.time() + + if now - last_call_ts < interval: + time.sleep(1) + continue + try: - status = is_game_online(league, game_id, lang) - # print(status) - is_online = status in {"scheduled", "online"} - is_finished = status in {"resultconfirmed", "result"} + data_standings = fetch_api_data( + session, + "standings", + host=HOST, + league=league, + season=season, + lang=lang, + ) - if is_finished: - logger.info(f"Матч {game_id} завершён.\nОстанавливаем мониторинг.") - break + if not data_standings: + logger.debug("[STANDINGS_THREAD] standings empty") + time.sleep(1) + continue - if is_online and not was_online: - logger.info( - f"Матч {game_id} перешёл в онлайн.\nЗапускаем быстрый опрос (1 сек)." + if isinstance(data_standings, list): + items = data_standings + else: + items = data_standings.get("items") or [] + + if not items: + logger.debug("[STANDINGS_THREAD] no items in standings") + last_call_ts = now + continue + + for item in items: + comp = item.get("comp", {}) + comp_name = ( + (comp.get("name") or "unknown_comp") + .replace(" ", "_") + .replace("|", "") ) - poller.start() - elif not is_online and was_online: - logger.info( - f"Матч {game_id} вышел из онлайна (или ещё не стартовал).\nОстанавливаем быстрый опрос." - ) - poller.stop() - was_online = is_online + if item.get("standings"): + standings_rows = item["standings"] - # Проверяем статус снова через минуту - stop_event.wait(STATUS_CHECK_INTERVAL_SEC) + df = pd.json_normalize(standings_rows) + + if "scores" in df.columns: + df = df.drop(columns=["scores"]) + + if ( + "totalWin" in df.columns + and "totalDefeat" in df.columns + and "totalGames" in df.columns + and "totalGoalPlus" in df.columns + and "totalGoalMinus" in df.columns + ): + tw = ( + pd.to_numeric(df["totalWin"], errors="coerce") + .fillna(0) + .astype(int) + ) + td = ( + pd.to_numeric(df["totalDefeat"], errors="coerce") + .fillna(0) + .astype(int) + ) + + df["w_l"] = tw.astype(str) + " / " + td.astype(str) + + def calc_percent(row): + win = row.get("totalWin", 0) + games = row.get("totalGames", 0) + + # гарантируем числа + try: + win = int(win) + except (TypeError, ValueError): + win = 0 + try: + games = int(games) + except (TypeError, ValueError): + games = 0 + + if games == 0 or row["w_l"] == "0 / 0": + return 0 + + return round(win * 100 / games + 0.000005) + + df["procent"] = df.apply(calc_percent, axis=1) + + tg_plus = ( + pd.to_numeric(df["totalGoalPlus"], errors="coerce") + .fillna(0) + .astype(int) + ) + tg_minus = ( + pd.to_numeric(df["totalGoalMinus"], errors="coerce") + .fillna(0) + .astype(int) + ) + + df["plus_minus"] = tg_plus - tg_minus + + standings_payload = df.to_dict(orient="records") + + filename = f"standings_{league}_{comp_name}" + atomic_write_json(filename, standings_payload) + logger.info(f"[STANDINGS_THREAD] сохранил {filename}.json") + + elif item.get("playoffPairs"): + playoff_rows = item["playoffPairs"] + df = pd.json_normalize(playoff_rows) + + standings_payload = df.to_dict(orient="records") + + filename = f"standings_{league}_{comp_name}" + atomic_write_json(filename, standings_payload) + logger.info( + f"[STANDINGS_THREAD] saved {filename}.json (playoffPairs, {len(standings_payload)} rows)" + ) + + else: + continue + + last_call_ts = now except Exception as e: - logger.warning(f"Сбой проверки статуса матча {game_id}: {e}") - # При ошибке — не дергаем быстро, подождём немного и повторим - stop_event.wait(POLL_INTERVAL_OFFLINE_SEC) + logger.warning(f"[STANDINGS_THREAD] ошибка в турнирном положении: {e}") - # Гарантированно остановим быстрый опрос при завершении - poller.stop() - logger.info(f"Мониторинг матча {game_id} остановлен.") + time.sleep(1) + + logger.info("[STANDINGS_THREAD] stop standings loop") -def next_midnight_local(now: datetime) -> datetime: - tomorrow = (now + timedelta(days=1)).date() - return datetime.combine(tomorrow, datetime.min.time(), tzinfo=APP_TZ) + timedelta( - minutes=5 - ) - # return now + timedelta(seconds=30) +# ============================================================================ +# 8. Суточный цикл: находим игру, следим в лайве, потом уходим спать +# ============================================================================ -def daily_rollover_loop( +def get_data_API( + session: requests.Session, league: str, team: str, lang: str, - season_getter, - schedule_getter, - monitor_mgr, stop_event: threading.Event, -): +) -> str: """ - Каждый день в ~00:05 по Europe/Moscow: - - узнаём актуальный сезон - - заново тянем расписание - - выбираем сегодняшнюю игру или последний сыгранный - - при наличии сегодняшней игры — перезапускаем монитор на неё + Один "дневной прогон" логики. + Возвращает day_state: + - "upcoming" -> матч сегодня (домашний), но ещё не начался + - "live_done" -> был live_loop и завершился + - "finished_now" -> матч уже завершён, отрендерили финал + - "no_game" -> сегодня игры нет вообще + - "error" -> что-то упало по дороге """ - while not stop_event.is_set(): - now = datetime.now(APP_TZ) - wakeup_at = next_midnight_local(now) - seconds = (wakeup_at - now).total_seconds() - logger.info( - # f"Ежедневка: проснусь {datetime.fromisoformat(wakeup_at.isoformat())} (через {int(seconds)} сек)." - f"Ежедневная проверка матча:\nпроснусь {wakeup_at.strftime('%Y-%m-%d %H:%M:%S')} (через {int(seconds)} сек)." + # 1. сезоны + json_seasons = fetch_api_data( + session, "seasons", host=HOST, league=league, lang=lang + ) + if not json_seasons: + logger.error("Не удалось получить список сезонов") + return "error" + + 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 "error" + + # 3. определяем игру + today_game, last_played = get_game_id(json_calendar, team) + print(today_game, last_played) + + # Ветка А: есть завершённая игра, но сегодня нет матча + if last_played and not today_game: + game_id = last_played["game"]["id"] + logger.info(f"Последний завершённый матч id={game_id}") + render_once_after_game(session, league, season, game_id, lang) + return "finished_now" + + # Ветка Б: матч сегодня есть (для домашней команды) + if today_game: + game_id = today_game["game"]["id"] + logger.info(f"Онлайн матч id={game_id}") + + # Обновляем api_* файлы сразу + fetch_api_data(session, "game", host=HOST, game_id=game_id, lang=lang) + fetch_api_data(session, "box-score", host=HOST, game_id=game_id) + fetch_api_data(session, "play-by-play", host=HOST, game_id=game_id) + live_status_raw = fetch_api_data( + session, "live-status", host=HOST, game_id=game_id ) - if stop_event.wait(seconds): - break - # Выполняем ежедневную проверку - try: - season = season_getter(league, lang) - games = schedule_getter(league, season, team, lang) - if not games: - logger.info( - f"Ежедневная проверка:\nу {team} нет игр в расписании сезона {season}." - ) - continue + # Определяем состояние матча + status_calendar = today_game["game"].get("gameStatus", "") + status_live = "" + # print(live_status_raw) + if isinstance(live_status_raw, dict): + # бывают ответы вида {"status":"404","message":"Not found","result":None} + ls_result = live_status_raw.get("result") + if isinstance(ls_result, dict): + status_live = ls_result.get("gameStatus", "") + # если result == None -> просто считаем, что live-статуса нет (ещё не начался) - today_game, last_played = pick_today_or_last_played( - games, datetime.now(APP_TZ) + effective_status = status_live or status_calendar + phase = classify_game_state_from_status(effective_status) + + if phase == "live": + # матч идёт → запускаем live_loop блокирующе + t = threading.Thread( + target=run_live_loop, + args=(league, season, game_id, lang, today_game["game"], stop_event), + daemon=False, ) - if today_game: - gid = today_game["game"]["id"] + t.start() + logger.info("[get_data_API] live thread spawned, waiting for it to finish") + + try: + t.join() + except KeyboardInterrupt: logger.info( - f"Сегодня у {team} есть игра: gameID={gid}. \nПерезапуск мониторинга." + "[get_data_API] KeyboardInterrupt while waiting live thread -> stop_event" ) - monitor_mgr.restart(gid, lang) - elif last_played: - gid = last_played["game"]["id"] - logger.info( - f"Сегодня у {team} нет игры. \nПоследняя сыгранная: gameID={gid}.\nМониторинг НЕ запускаем." - ) - else: - logger.info(f"Сегодня у {team} нет игры и нет предыдущих сыгранных.") - except Exception as e: - logger.warning(f"Ошибка ежедневной проверки: {e}") + stop_event.set() + t.join() + logger.info("[get_data_API] live thread finished") + return "live_done" -class MonitorManager: - """ - Управляет потоком мониторинга, чтобы можно было - безопасно перезапускать на новый gameId. - """ - - def __init__(self, league: str): - self.league = league - self._thread: threading.Thread | None = None - self._stop_event = threading.Event() - self._lock = threading.Lock() - - def restart(self, game_id: str, lang: str): - with self._lock: - self.stop() - self._stop_event = threading.Event() - self._thread = threading.Thread( - target=monitor_game_loop, - args=(self.league, game_id, lang, self._stop_event), - name=f"monitor-{game_id}", - daemon=True, + if phase == "upcoming": + logger.info( + f"Матч {game_id} сегодня, но ещё не начался (status={effective_status}). Подготовка pregame." ) - self._thread.start() - def stop(self): - if self._thread and self._thread.is_alive(): - self._stop_event.set() - self._thread.join(timeout=5) - self._thread = None + # дергаем pregame только один раз за матч + if not _pregame_done_for_game.get(game_id): + try: + pregame_raw = fetch_api_data( + session, + "pregame", + host=HOST, + league=league, + season=season, + game_id=game_id, + lang=lang, + ) + Pregame_data( + pregame_raw=pregame_raw, + game_stub=today_game["game"], + ) -# ========================== -# ---- MAIN -# ========================== + _pregame_done_for_game[game_id] = True + logger.info( + f"[get_data_API] pregame данные собраны для game_id={game_id}" + ) + + except Exception as e: + logger.exception( + f"[get_data_API] ошибка при подготовке pregame для {game_id}: {e}" + ) + + # матч сегодня, ждём старта + return "upcoming" + + if phase == "finished": + # матч уже закончен → однократно пререндерили всё и можем спать + render_once_after_game(session, league, season, game_id, lang) + return "finished_now" + + # на всякий случай (если API дал что-то новое) + logger.info( + f"[get_data_API] Неожиданная фаза '{phase}', status={effective_status}. Считаем как 'upcoming'." + ) + return "upcoming" + + # Ветка В: нет матча сегодня, нет последнего завершённого + logger.info("Для этой команды игр сегодня нет и нет завершённой последней игры.") + return "no_game" def main(): - # global MYHOST - parser = argparse.ArgumentParser(description="Game watcher") - parser.add_argument("--league", type=str, default=DEFAULT_LEAGUE, help="тег лиги") - parser.add_argument( - "--team", type=str, required=True, help="код/тег команды (например, BOS)" - ) - parser.add_argument("--lang", type=str, default="en", help="язык получения данных") - parser.add_argument( - "--log-level", type=str, default="INFO", help="DEBUG|INFO|WARNING|ERROR" - ) + parser = argparse.ArgumentParser() + parser.add_argument("--league", default="vtb") + parser.add_argument("--team", required=True) + parser.add_argument("--lang", default="en") args = parser.parse_args() - print(args) - # logger.info(f"Запуск программы пользователем: {MYHOST}") - logger.info( - f"Запуск с параметрами:\nleague={args.league}\nteam={args.team}\nlang={args.lang}" - ) - - league = validate_league_or_die(args.league) - team = args.team.lower() - - # 1) Узнать последний сезон - season = get_last_season_or_die(league, args.lang) - - - - - - # 2) Получить расписание для команды - team_games = get_team_schedule_or_die(league, season, team, args.lang) - if not team_games: - logger.warning("Расписание пустое — работа завершена.") - sys.exit(4) - - # 3) Найти сегодняшнюю или последнюю сыгранную игру - now = datetime.now(APP_TZ) - today_game, last_played = pick_today_or_last_played(team_games, now) - - monitor_mgr = MonitorManager(league=league) - - if today_game: - # В исходном расписании предполагалось наличие game.id - game_id = today_game["game"]["id"] - logger.info( - f"Сегодня у {team} есть игра: gameID={game_id}.\nЗапускаю мониторинг." - ) - monitor_mgr.restart(game_id, args.lang) - else: - if last_played: - game_id = last_played["game"]["id"] - try: - url = URL_GAME.format( - host=HOST, league=league, game_id=game_id, lang=args.lang - ) - game_json = fetch_json(url) - merged = ensure_merged_payload( - game_json, - game_meta={ - "id": game_json.get("result", {}).get("gameId"), - "league": args.league, - }, - ) - Json_Team_Generation(merged, out_dir="static", who="team1") - Json_Team_Generation(merged, out_dir="static", who="team2") - Team_Both_Stat(merged, out_dir="static") - Referee(merged, out_dir="static") - Scores_Quarter(merged, out_dir="static") - status_online_func(merged, out_dir="static") - # print(merged) - logger.info( - f"Сегодня у {team} нет игры.\nПоследняя сыгранная: gameID={game_id}.\nМониторинг не запускаю." - ) - except Exception as e: - logging.exception( - f"Оффлайн-сохранение для gameID={game_id}\nупало: {e}" - ) - else: - logger.info(f"Сегодня у {team} нет игры и нет предыдущих сыгранных.") - - # 4) Ежедневная перекладка расписания stop_event = threading.Event() - rollover_thread = threading.Thread( - target=daily_rollover_loop, - args=( - league, - team, - args.lang, - get_last_season_or_die, - get_team_schedule_or_die, - monitor_mgr, - stop_event, - ), - name="daily-rollover", - daemon=True, + + standings_session = create_session() + standings_thread = threading.Thread( + target=Standing_func, + args=(standings_session, args.league, None, args.lang, stop_event), + daemon=False, ) - rollover_thread.start() - - # 1.1) турнирная таблица - threads = [ - threading.Thread( - target=Standing_func, - args=(league, season, args.lang, stop_event), - name="standings",)] - - for t in threads: - t.start() - logger.debug(f"Поток {t.name} запущен.") + standings_thread.start() + logger.info("[MAIN] standings thread started (global)") - # Держим главный поток живым - try: - while True: - time.sleep(1) - except KeyboardInterrupt: - logger.info("Завершение по Ctrl+C…") - stop_event.set() - for t in threads: - t.join() - logger.debug(f"Поток {t.name} завершён.") - finally: - stop_event.set() - monitor_mgr.stop() - rollover_thread.join(timeout=5) - logger.info("Остановлено.") + while True: + session = create_session() + try: + day_state = get_data_API( + session, args.league, args.team, args.lang, stop_event + ) + except KeyboardInterrupt: + logger.info("KeyboardInterrupt -> останавливаем всё") + stop_event.set() + break + except Exception as e: + logger.exception(f"main loop crash: {e}") + day_state = "error" + now = datetime.now(APP_TZ) + + if day_state == "upcoming": + # матч сегодня, но ещё не начался → НЕ спим до завтра. + time.sleep(120) # 2 минуты опроса статуса до старта + continue + + if day_state == "error": + # что-то пошло не так → подожди минуту и попробуем ещё раз + time.sleep(60) + continue + + # сюда мы попадаем если: + # - live_done (лайв отработался до конца) + # - finished_now (матч уже был, всё посчитали) + # - no_game (сегодня матчей вообще нет) + # -> можно лечь до завтра 00:05 + tomorrow = (now + timedelta(days=1)).replace( + hour=0, minute=5, second=0, microsecond=0 + ) + sleep_seconds = (tomorrow - now).total_seconds() + if sleep_seconds < 0: + tomorrow = (now + timedelta(days=2)).replace( + hour=0, minute=5, second=0, microsecond=0 + ) + sleep_seconds = (tomorrow - now).total_seconds() + + hours_left = int(sleep_seconds // 3600) + mins_left = int((sleep_seconds % 3600) // 60) + + logger.info( + f"Работа за день завершена. Засыпаем до {tomorrow.strftime('%d.%m %H:%M')} " + f"(~{hours_left}.{mins_left} ч)." + ) + + try: + time.sleep(sleep_seconds) + except KeyboardInterrupt: + logger.info("KeyboardInterrupt во время сна -> выходим") + stop_event.set() + break + + stop_event.set() + standings_thread.join() + logger.info("[MAIN] standings thread stopped, shutdown complete") + + +# ============================================================================ +# 9. Точка входа +# ============================================================================ if __name__ == "__main__": main() diff --git a/get_data_new.py b/get_data_new.py deleted file mode 100644 index bc0bdfc..0000000 --- a/get_data_new.py +++ /dev/null @@ -1,2138 +0,0 @@ -import time -import os, tempfile -import json -import tempfile -import argparse -import platform -import logging -import logging.config -from datetime import datetime, timedelta, timezone -from zoneinfo import ZoneInfo -from typing import Any, Dict, List, Tuple, Optional -from pathlib import Path -import pandas as pd -import numpy as np - -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://deti.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 - -# Глобальный лок для потокобезопасной записи JSON -_write_lock_api = threading.Lock() -_write_lock_out = threading.Lock() -_pregame_done_for_game = {} - -# Карта всех ручек API, с интервалами опроса в секундах. -URLS = [ - { - "name": "seasons", - "url": "{host}/api/abc/comps/seasons?Tag={league}&Lang={lang}", - "interval": 86400, # раз в сутки - }, - { - "name": "standings", - "url": "{host}/api/abc/comps/standings?tag={league}&season={season}&lang={lang}", - "interval": 1800, # раз в 30 минут - }, - { - "name": "calendar", - "url": "{host}/api/abc/comps/calendar?Tag={league}&Season={season}&Lang={lang}&MaxResultCount=1000", - "interval": 86400, # раз в сутки - }, - { - "name": "game", - "url": "{host}/api/abc/games/game?Id={game_id}&Lang={lang}", - "interval": 60, # раз в 10 минут - }, - { - "name": "pregame", - "url": "{host}/api/abc/games/pregame?tag={league}&season={season}&Id={game_id}&Lang={lang}", - "interval": 86400, # раз в сутки - }, - { - "name": "pregame-fullstats", - "url": "{host}/api/abc/games/pregame-fullstats?tag={league}&season={season}&id={game_id}&lang={lang}", - "interval": 600, # раз в 10 минут - }, - { - "name": "live-status", - "url": "{host}/api/abc/games/live-status?id={game_id}", - "interval": 1, # каждую секунду - }, - { - "name": "box-score", - "url": "{host}/api/abc/games/box-score?Id={game_id}", - "interval": 1, # каждую секунду - }, - { - "name": "play-by-play", - "url": "{host}/api/abc/games/play-by-play?Id={game_id}", - "interval": 1, # каждую секунду - }, -] - - -# ============================================================================ -# 2. Логирование -# ============================================================================ - -if not os.path.exists("logs"): - os.makedirs("logs") - -LOG_CONFIG = { - "version": 1, - "handlers": { - "telegram": { - "class": "telegram_handler.TelegramHandler", - "level": "INFO", - "token": TELEGRAM_BOT_TOKEN, - "chat_id": TELEGRAM_CHAT_ID, - "formatter": "telegram", - }, - "console": { - "class": "logging.StreamHandler", - "level": "INFO", - "formatter": "simple", - "stream": "ext://sys.stdout", - }, - "file": { - "class": "logging.FileHandler", - "level": "DEBUG", - "formatter": "simple", - "filename": f"logs/GFX_{MYHOST}.log", - "encoding": "utf-8", - }, - }, - "loggers": { - __name__: {"handlers": ["console", "file", "telegram"], "level": "DEBUG"}, - }, - "formatters": { - "telegram": { - "class": "telegram_handler.HtmlFormatter", - "format": f"%(levelname)s [{MYHOST.upper()}] %(message)s", - "use_emoji": "True", - }, - "simple": { - "class": "logging.Formatter", - "format": "%(asctime)s %(levelname)-8s %(funcName)s() - %(message)s", - "datefmt": "%d.%m.%Y %H:%M:%S", - }, - }, -} - -logging.config.dictConfig(LOG_CONFIG) -logger = logging.getLogger(__name__) -logger.handlers[2].formatter.use_emoji = True - - -# ============================================================================ -# 3. I/O вспомогательные функции -# ============================================================================ - - -def _select_lock(path: str): - filename = os.path.basename(path) - # все сырые файлы мы называем api_*.json (api_game.json, api_box-score.json, ...) - if filename.startswith("api_"): - return _write_lock_api - return _write_lock_out - -def atomic_write_json(path: str, data: Any) -> None: - """ - Безопасно записывает JSON: - 1. Сериализуем в память (без локов). - 2. Под коротким локом - пишем tmp и делаем os.replace(). - """ - # 1. Готовим данные заранее - # ensure_ascii=False чтобы не терять юникод, indent=None чтобы не раздувать файл - payload = json.dumps(data, ensure_ascii=False, separators=(",", ":")) - full_path = "static/" + path + ".json" - try: - with open(full_path, "r", encoding="utf-8") as f: - if f.read() == payload: - return # ничего не поменялось -> не пишем, не fsync'им - except FileNotFoundError: - pass - target = Path(full_path) - tmp_fd, tmp_path = tempfile.mkstemp( - dir=target.parent, - prefix=target.name + ".tmp.", - text=True, - ) - os.close(tmp_fd) # мы будем писать сами - - lock = _select_lock(full_path) - with lock: - # 2a. Записываем полностью во временный файл - with open(tmp_path, "w", encoding="utf-8") as f: - f.write(payload) - f.flush() - os.fsync(f.fileno()) - - # 2b. Атомарно подменяем - os.replace(tmp_path, target) - -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( - total=3, - backoff_factor=0.5, - status_forcelist=[429, 500, 502, 503, 504], - ) - - session.mount("https://", HTTPAdapter(max_retries=retries)) - session.headers.update( - { - "Connection": "keep-alive", - "Accept": "application/json, */*", - "Accept-Encoding": "gzip, deflate, br", - "User-Agent": "game-watcher/1.0", - } - ) - - return session - - -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() - atomic_write_json(f"api_{name}", data) - return data - - -def get_items(data: dict) -> Optional[list]: - """ - Мелкий хелпер: берём первый список в ответе API. - Многие ручки отдают {"result":[...]} или {"seasons":[...]}. - Если находим список — возвращаем его. - Если нет — возвращаем None (значит, нужно брать весь dict). - """ - for k, v in data.items(): - if isinstance(v, list): - return data[k] - return None - - -def fetch_api_data( - session: requests.Session, name: str, name_save: str = None, **kwargs -) -> Any: - """ - Универсальный обёртчик над 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) - return endpoint_name, data - - if endpoint_name == "box-score": - 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) - return endpoint_name, data - - if endpoint_name == "game": - data = fetch_api_data(session, "game", host=HOST, game_id=game_id, lang=lang) - return endpoint_name, data - - if endpoint_name == "pregame-fullstats": - data = fetch_api_data( - session, - "pregame-fullstats", - host=HOST, - league=league, - season=season, - game_id=game_id, - lang=lang, - ) - return endpoint_name, data - - # fallback — "ну вдруг добавим что-то ещё" - data = fetch_api_data(session, endpoint_name, host=HOST, game_id=game_id, lang=lang) - return endpoint_name, data - - -def get_interval_by_name(name: str) -> int: - """ - Возвращает рекомендуемый интервал опроса эндпоинта в секундах, - как задано в URLS. - """ - 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 (обычно уже с таймзоной лиги) - 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 - - raw = g.get("defaultZoneDateTime") or g.get("scheduledTime") or g.get("startTime") - if raw: - try: - dt = datetime.fromisoformat(raw) # ISO-8601 - return dt.astimezone(APP_TZ) - except Exception as e: - raise RuntimeError(f"Ошибка парсинга ISO времени '{raw}': {e}") - - # fallback: localDate + localTime, формата "30.09.2025" + "19:00" - ld, lt = g.get("localDate"), g.get("localTime") - if ld and lt: - try: - naive = datetime.strptime(f"{ld} {lt}", "%d.%m.%Y %H:%M") - aware = naive.replace(tzinfo=APP_TZ) - return aware - except Exception as e: - raise RuntimeError(f"Ошибка парсинга localDate/localTime '{ld} {lt}': {e}") - - raise RuntimeError( - "Не найдено ни одного подходящего поля времени (defaultZoneDateTime/" - "scheduledTime/startTime/localDate+localTime)." - ) - - -def get_game_id( - team_games: List[dict], - team: str, -) -> Tuple[Optional[dict], Optional[dict]]: - """ - Находим интересующую нас игру. - - Логика (важно): - - считаем, что интересующая нас команда — это team1 (домашняя), - и сравниваем по имени. - - если есть игра сегодня -> это today_game - - иначе берём последнюю уже завершённую игру -> last_played - - возвращаем (today_game, last_played) - - Если и того и другого нет -> (None, None). - """ - now = datetime.now(APP_TZ) - today = now.date() - today_game = None - last_played = None - - 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 - and g["team1"]["name"].lower() == team.lower() - ): - today_game = g - last_played = None - - # Последняя завершённая игра (resultconfirmed) - elif ( - start <= now - and status == "resultconfirmed" - and g["team1"]["name"].lower() == team.lower() - ): - today_game = None - last_played = g - - return today_game, last_played - - -def is_game_live(game_obj: dict) -> bool: - """ - Пытаемся понять, идёт ли матч прямо сейчас. - - Правила: - - 'resultconfirmed' / 'finished' / 'result' => матч уже окончен - - 'scheduled' / 'notstarted' / 'draft' => матч ещё не начался - - всё остальное считаем лайвом (в том числе 'online', 'inprogress', и т.п.) - """ - status = (game_obj.get("gameStatus") or "").lower() - - if status in ("resultconfirmed", "finished", "result"): - return False - if status in ("notstarted", "draft"): - return False - return True - - -def classify_game_state_from_status(status_raw: str) -> str: - """ - Делит статус игры на три фазы: - - "finished" -> матч точно завершён - - "upcoming" -> матч ещё не начался, но он сегодня - - "live" -> матч идёт - - Используется в get_data_API(), чтобы решить, что делать дальше. - """ - status = (status_raw or "").lower() - if status in ("resultconfirmed", "finished", "result"): - return "finished" - if status in ("", "notstarted", "draft"): - return "upcoming" - # всё остальное считаем лайвом - return "live" - - -# ============================================================================ -# 6. Лайв-петля: опрос API и поток рендера -# ============================================================================ - - -def poll_game_live( - session: requests.Session, - league: str, - season: str, - game_id: int, - lang: str, - game_meta: dict, - stop_event: threading.Event, -) -> None: - 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: - 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) - if now - last_call[ep] >= interval: - to_run.append(ep) - - futures = [] - if to_run: - for ep in to_run: - futures.append( - executor.submit( - poll_one_endpoint, - session, - ep, - league, - season, - game_id, - lang, - ) - ) - - 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": - # data может быть: - # {"status":"404","message":"Not found","result":None} - # или {"status":"200","result":{"gameStatus":"Online", ...}} - ls_result = ( - data.get("result") if isinstance(data, dict) else None - ) - game_status = "" - if isinstance(ls_result, dict): - game_status = (ls_result.get("gameStatus") or "").lower() - - if game_status in ("resultconfirmed", "finished", "result"): - logger.info( - f"[POLL] Game {game_id} finished by live-status" - ) - game_finished = True - - except Exception as e: - # логируем, но не роняем поток - logger.exception(f"[POLL] poll endpoint error: {e}") - - # страховка: календарь говорит, что матч не лайв -> выходим - if not is_game_live(game_meta): - logger.info(f"[POLL] Game {game_id} no longer live by calendar meta") - break - - if game_finished: - break - - time.sleep(0.2) - - if stop_event.is_set(): - logger.info( - f"[POLL] stop_event set after sleep -> break live poll for game {game_id}" - ) - break - - -def build_render_state() -> dict: - """ - Собирает итоговое состояние матча (merged dict) для графики/внешки. - Возвращает минимально возможный state, даже если часть данных ещё не доступна. - """ - - game_data_raw = 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") - - # Без api_game у нас вообще нет каркаса матча -> это критично - if not game_data_raw or "result" not in game_data_raw: - raise RuntimeError("build_render_state(): api_game/result отсутствует") - - game_data = game_data_raw["result"] - - # Защитимся от отсутствия ключевых структур - # Убедимся, что есть поля, которые ждёт остальной код - game_data.setdefault("teams", []) - game_data.setdefault("team1", {}) - game_data.setdefault("team2", {}) - game_data.setdefault("game", {}) - # plays - список событий - game_data["plays"] = (play_by_play_data or {}).get("result", []) or [] - - # live_status - текущее состояние периода/секунды - if live_status_data and isinstance(live_status_data.get("result"), dict): - game_data["live_status"] = live_status_data["result"] - else: - # дадим заготовку, чтобы Play_By_Play не падал - game_data["live_status"] = { - "period": 1, - "second": 0, - "gameStatus": game_data.get("game", {}).get("gameStatus", ""), - } - - # box-score -> статы игроков и команд - if ( - box_score_data - and isinstance(box_score_data.get("result"), dict) - and "teams" in box_score_data["result"] - and box_score_data["result"]["teams"] is not None - ): - for index_team, team in enumerate(game_data.get("teams", [])[1:]): - # box_team может отсутствовать, если индексы не совпали или сервер отдал None - box_teams_list = box_score_data["result"]["teams"] - if ( - isinstance(box_teams_list, list) - and index_team < len(box_teams_list) - and box_teams_list[index_team] is not None - ): - box_team = box_teams_list[index_team] - else: - box_team = {} - - # переносим статы игроков - for player in team.get("starts", []): - stat = None - if isinstance(box_team.get("starts"), list): - stat = next( - ( - s - for s in box_team["starts"] - if s.get("startNum") == player.get("startNum") - ), - None, - ) - if stat: - player["stats"] = stat - - # total по команде - team["total"] = ( - box_team.get("total", {}) if isinstance(box_team, dict) else {} - ) - - # периоды и общий счёт - if isinstance(box_score_data["result"], dict): - game_data["scoreByPeriods"] = ( - box_score_data["result"].get("scoreByPeriods") or [] - ) - game_data["fullScore"] = box_score_data["result"].get("fullScore") or {} - else: - # если box-score нет ещё: - game_data.setdefault("scoreByPeriods", []) - game_data.setdefault("fullScore", {}) - # а ещё надо, чтобы у каждой команды было хотя бы .total = {} - for team in game_data.get("teams", []): - team.setdefault("total", {}) - for starter in team.get("starts", []): - starter.setdefault("stats", {}) - - merged: Dict[str, Any] = { - "meta": { - "generatedAt": _now_iso(), - "sourceHints": { - "boxScoreHas": "yes" if box_score_data else "no", - "pbpLen": str(len(game_data["plays"])), - }, - }, - "result": game_data, - } - return merged - - -def render_loop(stop_event: threading.Event, out_name: str = "game") -> None: - logger.info("[RENDER_THREAD] start render loop") - - with ThreadPoolExecutor(max_workers=6) as pool: - while not stop_event.is_set(): - try: - try: - state = build_render_state() - except Exception as build_err: - logger.debug(f"[RENDER_THREAD] build_render_state not ready: {build_err}") - time.sleep(0.2) - continue - - tasks = { - "team_stats": pool.submit(Team_Both_Stat, state), - "team1": pool.submit(Json_Team_Generation, state, who="team1"), - "team2": pool.submit(Json_Team_Generation, state, who="team2"), - "scores": pool.submit(Scores_Quarter, state), - "referee": pool.submit(Referee, state), - "pbp": pool.submit(Play_By_Play, state), - } - - # аккуратно собрать исключения, но не умереть целиком - for name, fut in tasks.items(): - try: - fut.result() - except Exception as e: - logger.debug(f"[RENDER_THREAD] skip {name}: {e}") - - # остальное можно сделать синхронно, это быстро - try: - live_status_to_write = [] - rs = state.get("result", {}) - if isinstance(rs, dict) and "live_status" in rs: - live_status_to_write = [rs["live_status"]] - atomic_write_json("live_status", live_status_to_write) - except Exception as e: - logger.debug(f"[RENDER_THREAD] skip live_status write: {e}") - - try: - atomic_write_json(out_name, state.get("result", {})) - except Exception as e: - logger.debug(f"[RENDER_THREAD] skip {out_name}.json write: {e}") - - except Exception as ex: - logger.exception(f"[RENDER_THREAD] unexpected error: {ex}") - - time.sleep(1) - - 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, -): - 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,), - 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 worker threads for game_id={game_id}") - - render_thread.join() - - logger.info(f"[LIVE_THREAD] stop live loop for game_id={game_id}") - - -def Referee(merged: dict, *, out_dir: str = "static") -> None: - """ - Поток, создающий JSON-файл с информацией о судьях матча. - """ - logger.info("START making json for referee") - - desired_order = [ - "Crew chief", - "Referee 1", - "Referee 2", - "Commissioner", - "Ст.судья", - "Судья 1", - "Судья 2", - "Комиссар", - ] - - try: - # Найти судей (teamNumber == 0) - team_ref = next( - (t for t in merged["result"]["teams"] if t["teamNumber"] == 0), None - ) - if not team_ref: - logger.warning("Не найдена судейская бригада в данных.") - - referees_raw = team_ref.get("starts", []) - referees = [] - - for r in referees_raw: - flag_code = r.get("countryId", "").lower() if r.get("countryName") else "" - referees.append( - { - "displayNumber": r.get("displayNumber", ""), - "positionName": r.get("positionName", ""), - "lastNameGFX": f"{r.get('firstName', '')} {r.get('lastName', '')}".strip(), - "secondName": r.get("secondName", ""), - "birthday": r.get("birthday", ""), - "age": r.get("age", 0), - "flag": f"https://flagicons.lipis.dev/flags/4x3/{flag_code}.svg", - } - ) - - # Сортировка по позиции - referees = sorted( - referees, - key=lambda x: ( - desired_order.index(x["positionName"]) - if x["positionName"] in desired_order - else len(desired_order) - ), - ) - out_path = "referee" - atomic_write_json(out_path, referees) - logging.info("Сохранил payload: {out_path}") - - except Exception as e: - logger.error(f"Ошибка в Referee потоке: {e}", exc_info=True) - - -def Play_By_Play(data: dict) -> None: - """ - Поток, обновляющий JSON-файл с последовательностью бросков в матче. - """ - logger.info("START making json for play-by-play") - - try: - game_data = data["result"] if "result" in data else data - - if not game_data: - logger.debug("game_online_data отсутствует") - return - - teams = game_data["teams"] - team1_data = next((i for i in teams if i.get("teamNumber") == 1), None) - team2_data = next((i for i in teams if i.get("teamNumber") == 2), None) - - if not team1_data or not team2_data: - logger.warning("Не удалось получить команды из game_online_data") - return - team1_name = game_data["team1"]["name"] - team2_name = game_data["team2"]["name"] - team1_startnum = [ - p["startNum"] - for p in team1_data.get("starts", []) - if p.get("startRole") == "Player" - ] - team2_startnum = [ - p["startNum"] - for p in team2_data.get("starts", []) - if p.get("startRole") == "Player" - ] - - plays = game_data.get("plays", []) - if not plays: - logger.debug("нет данных в play-by-play") - return - - # Получение текущего времени игры - json_live_status = ( - data["result"]["live_status"] - if "result" in data and "live_status" in data["result"] - else None - ) - last_event = plays[-1] - - if json_live_status is None: - period = last_event.get("period", 1) - second = 0 - else: - period = (json_live_status or {}).get("result", {}).get("period", 1) - second = (json_live_status or {}).get("result", {}).get("second", 0) - - # Создание DataFrame из событий - df = pd.DataFrame(plays[::-1]) - - df_goals = df[df["play"].isin([1, 2, 3])].copy() - if df_goals.empty: - logger.debug("нет данных о голах в play-by-play") - return - - # Расчёты по очкам и времени - df_goals["score1"] = ( - df_goals["startNum"].isin(team1_startnum) * df_goals["play"] - ) - df_goals["score2"] = ( - df_goals["startNum"].isin(team2_startnum) * df_goals["play"] - ) - - df_goals["score_sum1"] = df_goals["score1"].fillna(0).cumsum() - df_goals["score_sum2"] = df_goals["score2"].fillna(0).cumsum() - - df_goals["new_sec"] = ( - pd.to_numeric(df_goals["sec"], errors="coerce").fillna(0).astype(int) // 10 - ) - df_goals["time_now"] = (600 if period < 5 else 300) - second - df_goals["quar"] = period - df_goals["period"] - - df_goals["diff_time"] = np.where( - df_goals["quar"] == 0, - df_goals["time_now"] - df_goals["new_sec"], - (600 * df_goals["quar"] - df_goals["new_sec"]) + df_goals["time_now"], - ) - - df_goals["diff_time_str"] = df_goals["diff_time"].apply( - lambda x: (f"{x // 60}:{str(x % 60).zfill(2)}" if isinstance(x, int) else x) - ) - - # Текстовые поля - def generate_text(row, with_time=False, is_rus=False): - s1, s2 = int(row["score_sum1"]), int(row["score_sum2"]) - team = ( - team1_name - if not pd.isna(row["score1"]) and row["score1"] != 0 - else team2_name - ) - - # правильный порядок счёта в зависимости от команды - if team == team1_name: - score = f"{s1}-{s2}" - else: - score = f"{s2}-{s1}" - - time_str = ( - f" за {row['diff_time_str']}" - if is_rus - else f" in last {row['diff_time_str']}" - ) - prefix = "рывок" if is_rus else "run" - - return f"{team} {score} {prefix}{time_str if with_time else ''}" - - df_goals["text_rus"] = df_goals.apply( - lambda r: generate_text(r, is_rus=True, with_time=False), axis=1 - ) - df_goals["text_time_rus"] = df_goals.apply( - lambda r: generate_text(r, is_rus=True, with_time=True), axis=1 - ) - df_goals["text"] = df_goals.apply( - lambda r: generate_text(r, is_rus=False, with_time=False), axis=1 - ) - df_goals["text_time"] = df_goals.apply( - lambda r: generate_text(r, is_rus=False, with_time=True), axis=1 - ) - - df_goals["team"] = df_goals["score1"].apply( - lambda x: team1_name if not pd.isna(x) and x != 0 else team2_name - ) - - # Удаление лишнего - drop_cols = [ - "children", - "start", - "stop", - "hl", - "sort", - "startNum", - "zone", - "x", - "y", - ] - df_goals.drop(columns=drop_cols, inplace=True, errors="ignore") - - # Порядок колонок - main_cols = ["text", "text_time"] - all_cols = main_cols + [col for col in df_goals.columns if col not in main_cols] - df_goals = df_goals[all_cols] - - # Сохранение JSON - directory = "static" - os.makedirs(directory, exist_ok=True) - filepath = os.path.join(directory, "play_by_play.json") - - df_goals.to_json(filepath, orient="records", force_ascii=False) - logger.debug("Успешно положил данные об play-by-play в файл") - except Exception as e: - logger.error(f"Ошибка в Play_By_Play: {e}", exc_info=True) - - -def render_once_after_game( - session: requests.Session, - league: str, - season: str, - game_id: int, - lang: str, - out_name: str = "game", -) -> None: - """ - Одноразовая генерация всех выходных json-файлов (team_stats.json, - team1.json, team2.json, scores.json, live_status.json, game.json и т.д.) - без запуска вечного render_loop. - - Что делает: - - один раз тянет /game из API (по game_id) - - собирает полный state (build_render_state) - - считает статистику команд, игроков, судей и счёт по четвертям - - сохраняет все соответствующие JSON в static/ - - Используется, когда матч уже завершён (finished/resultconfirmed) - или нет лайва. - """ - try: - logger.info(f"[RENDER_ONCE] Fetching final game snapshot for game_id={game_id}") - # один запрос к API (ручка "game") - state = fetch_api_data( - session, - "game", - host=HOST, - game_id=game_id, - lang=lang, - ) - - Team_Both_Stat(state) - Json_Team_Generation(state, who="team1") - Json_Team_Generation(state, who="team2") - Scores_Quarter(state) - Referee(state) - Play_By_Play(state) - - atomic_write_json(out_name, state["result"]) - - logger.info("[RENDER_ONCE] финальные json сохранены успешно") - - except Exception as ex: - logger.exception(f"[RENDER_ONCE] error while building final state: {ex}") - - -# ============================================================================ -# 7. Постобработка статистики для вывода -# ============================================================================ - - -def format_time(seconds: float | int) -> str: - """ - Удобный формат времени для игроков: - 71 -> "1:11" - 0 -> "0:00" - Любые кривые значения -> "0:00". - """ - 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, - *, - who: Optional[str] = None, -) -> None: - """ - Формирует и записывает несколько JSON-файлов по составу и игрокам команды: - - .json (полный список игроков с метриками) - - topTeam1.json / topTeam2.json (топ-игроки) - - started_team1.json / started_team2.json (игроки на паркете) - - Вход: - merged: словарь из build_render_state() - who: "team1" или "team2" - """ - if who == "team1": - payload = next( - (i for i in merged["result"]["teams"] if i["teamNumber"] == 1), None - ) - elif who == "team2": - 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"), - ("Forward", "F"), - ("Power Forward", "PF"), - ("Small Forward", "SF"), - ("Shooting Guard", "SG"), - ("Point Guard", "PG"), - ("Forward-Center", "FC"), - ] - - starts = payload.get("starts", []) - team_rows = [] - - for item in starts: - 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.get("positionName") or "").lower() - ][0] - if any( - r[0].lower() == (item.get("positionName") or "").lower() - for r in role_list - ) - else "" - ), - "NameGFX": ( - 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.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"{stats.get('goal2',0)+stats.get('goal3',0)}/" - f"{stats.get('shot2',0)+stats.get('shot3',0)}" - if 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": ( - 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(stats.get("second", 0)), - "pts1q": 0, - "pts2q": 0, - "pts3q": 0, - "pts4q": 0, - "pts1h": 0, - "pts2h": 0, - "Name1GFX": (item.get("firstName") or "").strip(), - "Name2GFX": (item.get("lastName") or "").strip(), - "photoGFX": ( - os.path.join( - "D:\\Photos", - merged["result"]["league"]["abcName"], - merged["result"][who]["name"], - f"{item.get('displayNumber')}.png", - ) - if item.get("startRole") == "Player" - else "" - ), - "isOnCourt": stats.get("isOnCourt", False), - } - 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, - } - sorted_team = sorted( - team_rows, - key=lambda x: role_priority.get(x.get("startRole", 99), 99), - ) - - # пишем полный ростер команды - atomic_write_json(who, sorted_team) - logger.info(f"Сохранил payload: {who}.json") - - # топ-игроки по очкам/подборам/ассистам и т.д. - top_sorted_team = sorted( - (p for p in sorted_team if p.get("startRole") in ["Player", ""]), - key=lambda x: ( - 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, - ) - - # пустые строки не должны ломать 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_name, top_sorted_team) - logger.info(f"Сохранил payload: {top_name}.json") - - # кто прямо сейчас на площадке - started_team = sorted( - ( - p - for p in sorted_team - if p.get("startRole") == "Player" and p.get("isOnCourt") is True - ), - key=lambda x: int(x.get("num") or 0), - ) - started_name = f"started_{who}" - atomic_write_json(started_name, started_team) - logger.info(f"Сохранил payload: {started_name}.json") - - -def time_outs_func(data_pbp: List[dict]) -> Tuple[str, int, str, int]: - """ - Считает таймауты для обеих команд и формирует читабельные строки вида: - "2 Time-outs left in 2nd half" - - Возвращает: - (строка_для_команды1, остаток1, строка_для_команды2, остаток2) - """ - timeout1 = [] - timeout2 = [] - - for event in data_pbp: - 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]: - 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[Any], float]: - """ - Считает командные агрегаты: - - средний возраст - - очки со старта vs со скамейки, + их проценты - - средний рост - - Возвращает кортеж: - (avg_age, [start_pts, start%, bench_pts, bench%], avg_height_cm) - """ - 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") or {} - if stats: - if stats.get("isStart") is True: - points_start += stats.get("points", 0) - elif stats.get("isStart") is False: - points_bench += stats.get("points", 0) - - total_age += player.get("age") or 0 - total_height += player.get("height") 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: List[Any], - avg_height: float, - timeout_str: str, - timeout_left: int, -) -> dict: - """ - Берёт словарь total по команде (очки, подборы, броски и т.д.), - добавляет: - - проценты попаданий - - средний возраст / рост - - очки старт / бенч - - информацию по таймаутам - и всё приводит к строкам (для UI, чтобы не ловить типы). - - Возвращает обновлённый словарь. - """ - - def safe_int(v): - 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) -> None: - """ - Формирует сводку по двум командам и пишет её в static/team_stats.json. - """ - 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("Не найдены обе команды в данных") - 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_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, - ) - - result_json = [] - for key in total_1: - 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( - { - "name": key, - "nameGFX_rus": stat_rus, - "nameGFX_eng": stat_eng, - "val1": val1, - "val2": val2, - } - ) - - atomic_write_json("team_stats", result_json) - logger.info("Сохранил payload: team_stats.json") - - except Exception as e: - logger.error(f"Ошибка при обработке командной статистики: {e}", exc_info=True) - - -def Pregame_data_json(data: dict) -> None: - teams = [] - for data_team in (data["teamStats1"], data["teamStats2"]): - temp_team = { - "team": data_team["team"]["name"], - "games": data_team["games"], - "points": round( - (data_team["totalStats"]["points"] / data_team["games"]), 1 - ), - "points_2": round( - ( - data_team["totalStats"]["goal2"] - * 100 - / data_team["totalStats"]["shot2"] - ), - 1, - ), - "points_3": round( - ( - data_team["totalStats"]["goal3"] - * 100 - / data_team["totalStats"]["shot3"] - ), - 1, - ), - "points_23": round( - ( - data_team["totalStats"]["goal23"] - * 100 - / data_team["totalStats"]["shot23"] - ), - 1, - ), - "points_1": round( - ( - data_team["totalStats"]["goal1"] - * 100 - / data_team["totalStats"]["shot1"] - ), - 1, - ), - "assists": round( - (data_team["totalStats"]["assist"] / data_team["games"]), 1 - ), - "rebounds": round( - ( - ( - data_team["totalStats"]["defRebound"] - + data_team["totalStats"]["offRebound"] - ) - / data_team["games"] - ), - 1, - ), - "steals": round((data_team["totalStats"]["steal"] / data_team["games"]), 1), - "turnovers": round( - (data_team["totalStats"]["turnover"] / data_team["games"]), 1 - ), - "blocks": round( - (data_team["totalStats"]["blockShot"] / data_team["games"]), 1 - ), - "fouls": round((data_team["totalStats"]["foul"] / data_team["games"]), 1), - } - teams.append(temp_team) - atomic_write_json("team_comparison", teams) - logger.info("Сохранил payload: team_comparison.json") - - -def Pregame_data(pregame_raw: dict, game_stub: dict) -> None: - """ - Обработка предматчевых данных (Pregame). - Вызывается один раз ДО начала матча, если матч сегодня. - - Вход: - pregame_raw -> ответ от /pregame (dict или None) - game_stub -> today_game["game"] из календаря (минимальная информация о матче) - """ - try: - out = { - "game": game_stub or {}, - "pregame": ( - pregame_raw.get("result") - if isinstance(pregame_raw, dict) - else pregame_raw - ), - "generatedAt": _now_iso(), - } - Pregame_data_json(out["pregame"]) - # сохраняем файл - # atomic_write_json(out["pregame"], "pregame") - # logger.info("Сохранил payload: pregame.json") - except Exception as e: - logger.error(f"Ошибка в Pregame_data: {e}", exc_info=True) - - -def Scores_Quarter(merged: dict) -> None: - """ - Пишет счёт по четвертям и овертаймам в static/scores.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: - 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.") - 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("Нет данных по счёту, сохраняем пустые значения.") - - atomic_write_json("scores", score_by_quarter) - logger.info("Сохранил payload: scores.json") - - except Exception as e: - logger.error(f"Ошибка в Scores_Quarter: {e}", exc_info=True) - - -def Standing_func( - session: requests.Session, - league: str, - season: str, - lang: str, - stop_event: threading.Event, - out_dir: str = "static", -) -> None: - """ - Фоновый поток с турнирной таблицей (standings). - """ - logger.info("[STANDINGS_THREAD] start standings loop") - - last_call_ts = 0 - json_seasons = fetch_api_data( - session, "seasons", host=HOST, league=league, lang=lang - ) - season = json_seasons[0]["season"] - - interval = get_interval_by_name("standings") - - while not stop_event.is_set(): - now = time.time() - - if now - last_call_ts < interval: - time.sleep(1) - continue - - try: - data_standings = fetch_api_data( - session, - "standings", - host=HOST, - league=league, - season=season, - lang=lang, - ) - - if not data_standings: - logger.debug("[STANDINGS_THREAD] standings empty") - time.sleep(1) - continue - - if isinstance(data_standings, list): - items = data_standings - else: - items = data_standings.get("items") or [] - - if not items: - logger.debug("[STANDINGS_THREAD] no items in standings") - last_call_ts = now - continue - - for item in items: - comp = item.get("comp", {}) - comp_name = ( - (comp.get("name") or "unknown_comp") - .replace(" ", "_") - .replace("|", "") - ) - - if item.get("standings"): - standings_rows = item["standings"] - - df = pd.json_normalize(standings_rows) - - if "scores" in df.columns: - df = df.drop(columns=["scores"]) - - if ( - "totalWin" in df.columns - and "totalDefeat" in df.columns - and "totalGames" in df.columns - and "totalGoalPlus" in df.columns - and "totalGoalMinus" in df.columns - ): - tw = ( - pd.to_numeric(df["totalWin"], errors="coerce") - .fillna(0) - .astype(int) - ) - td = ( - pd.to_numeric(df["totalDefeat"], errors="coerce") - .fillna(0) - .astype(int) - ) - - df["w_l"] = tw.astype(str) + " / " + td.astype(str) - - def calc_percent(row): - win = row.get("totalWin", 0) - games = row.get("totalGames", 0) - - # гарантируем числа - try: - win = int(win) - except (TypeError, ValueError): - win = 0 - try: - games = int(games) - except (TypeError, ValueError): - games = 0 - - if games == 0 or row["w_l"] == "0 / 0": - return 0 - - return round(win * 100 / games + 0.000005) - - df["procent"] = df.apply(calc_percent, axis=1) - - tg_plus = ( - pd.to_numeric(df["totalGoalPlus"], errors="coerce") - .fillna(0) - .astype(int) - ) - tg_minus = ( - pd.to_numeric(df["totalGoalMinus"], errors="coerce") - .fillna(0) - .astype(int) - ) - - df["plus_minus"] = tg_plus - tg_minus - - standings_payload = df.to_dict(orient="records") - - filename = f"standings_{league}_{comp_name}" - atomic_write_json(filename, standings_payload) - logger.info(f"[STANDINGS_THREAD] сохранил {filename}.json") - - elif item.get("playoffPairs"): - playoff_rows = item["playoffPairs"] - df = pd.json_normalize(playoff_rows) - - standings_payload = df.to_dict(orient="records") - - filename = f"standings_{league}_{comp_name}" - atomic_write_json(filename, standings_payload) - logger.info( - f"[STANDINGS_THREAD] saved {filename}.json (playoffPairs, {len(standings_payload)} rows)" - ) - - else: - continue - - last_call_ts = now - - except Exception as e: - logger.warning(f"[STANDINGS_THREAD] ошибка в турнирном положении: {e}") - - time.sleep(1) - - logger.info("[STANDINGS_THREAD] stop standings loop") - - -# ============================================================================ -# 8. Суточный цикл: находим игру, следим в лайве, потом уходим спать -# ============================================================================ - - -def get_data_API( - session: requests.Session, - league: str, - team: str, - lang: str, - stop_event: threading.Event, -) -> str: - """ - Один "дневной прогон" логики. - Возвращает day_state: - - "upcoming" -> матч сегодня (домашний), но ещё не начался - - "live_done" -> был live_loop и завершился - - "finished_now" -> матч уже завершён, отрендерили финал - - "no_game" -> сегодня игры нет вообще - - "error" -> что-то упало по дороге - """ - # 1. сезоны - json_seasons = fetch_api_data( - session, "seasons", host=HOST, league=league, lang=lang - ) - if not json_seasons: - logger.error("Не удалось получить список сезонов") - return "error" - - 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 "error" - - # 3. определяем игру - today_game, last_played = get_game_id(json_calendar, team) - print(today_game, last_played) - - # Ветка А: есть завершённая игра, но сегодня нет матча - if last_played and not today_game: - game_id = last_played["game"]["id"] - logger.info(f"Последний завершённый матч id={game_id}") - render_once_after_game(session, league, season, game_id, lang) - return "finished_now" - - # Ветка Б: матч сегодня есть (для домашней команды) - if today_game: - game_id = today_game["game"]["id"] - logger.info(f"Онлайн матч id={game_id}") - - # Обновляем api_* файлы сразу - fetch_api_data(session, "game", host=HOST, game_id=game_id, lang=lang) - fetch_api_data(session, "box-score", host=HOST, game_id=game_id) - fetch_api_data(session, "play-by-play", host=HOST, game_id=game_id) - live_status_raw = fetch_api_data( - session, "live-status", host=HOST, game_id=game_id - ) - - # Определяем состояние матча - status_calendar = today_game["game"].get("gameStatus", "") - status_live = "" - # print(live_status_raw) - if isinstance(live_status_raw, dict): - # бывают ответы вида {"status":"404","message":"Not found","result":None} - ls_result = live_status_raw.get("result") - if isinstance(ls_result, dict): - status_live = ls_result.get("gameStatus", "") - # если result == None -> просто считаем, что live-статуса нет (ещё не начался) - - effective_status = status_live or status_calendar - phase = classify_game_state_from_status(effective_status) - - if phase == "live": - # матч идёт → запускаем live_loop блокирующе - t = threading.Thread( - target=run_live_loop, - args=(league, season, game_id, lang, today_game["game"], stop_event), - daemon=False, - ) - t.start() - logger.info("[get_data_API] live thread spawned, waiting for it to finish") - - try: - t.join() - except KeyboardInterrupt: - logger.info( - "[get_data_API] KeyboardInterrupt while waiting live thread -> stop_event" - ) - stop_event.set() - t.join() - - logger.info("[get_data_API] live thread finished") - return "live_done" - - if phase == "upcoming": - logger.info( - f"Матч {game_id} сегодня, но ещё не начался (status={effective_status}). Подготовка pregame." - ) - - # дергаем pregame только один раз за матч - if not _pregame_done_for_game.get(game_id): - try: - pregame_raw = fetch_api_data( - session, - "pregame", - host=HOST, - league=league, - season=season, - game_id=game_id, - lang=lang, - ) - - Pregame_data( - pregame_raw=pregame_raw, - game_stub=today_game["game"], - ) - - _pregame_done_for_game[game_id] = True - logger.info( - f"[get_data_API] pregame данные собраны для game_id={game_id}" - ) - - except Exception as e: - logger.exception( - f"[get_data_API] ошибка при подготовке pregame для {game_id}: {e}" - ) - - # матч сегодня, ждём старта - return "upcoming" - - if phase == "finished": - # матч уже закончен → однократно пререндерили всё и можем спать - render_once_after_game(session, league, season, game_id, lang) - return "finished_now" - - # на всякий случай (если API дал что-то новое) - logger.info( - f"[get_data_API] Неожиданная фаза '{phase}', status={effective_status}. Считаем как 'upcoming'." - ) - return "upcoming" - - # Ветка В: нет матча сегодня, нет последнего завершённого - logger.info("Для этой команды игр сегодня нет и нет завершённой последней игры.") - return "no_game" - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--league", default="vtb") - parser.add_argument("--team", required=True) - parser.add_argument("--lang", default="en") - args = parser.parse_args() - - stop_event = threading.Event() - - standings_session = create_session() - standings_thread = threading.Thread( - target=Standing_func, - args=(standings_session, args.league, None, args.lang, stop_event), - daemon=False, - ) - standings_thread.start() - logger.info("[MAIN] standings thread started (global)") - - while True: - session = create_session() - try: - day_state = get_data_API( - session, args.league, args.team, args.lang, stop_event - ) - except KeyboardInterrupt: - logger.info("KeyboardInterrupt -> останавливаем всё") - stop_event.set() - break - except Exception as e: - logger.exception(f"main loop crash: {e}") - day_state = "error" - - now = datetime.now(APP_TZ) - - if day_state == "upcoming": - # матч сегодня, но ещё не начался → НЕ спим до завтра. - time.sleep(120) # 2 минуты опроса статуса до старта - continue - - if day_state == "error": - # что-то пошло не так → подожди минуту и попробуем ещё раз - time.sleep(60) - continue - - # сюда мы попадаем если: - # - live_done (лайв отработался до конца) - # - finished_now (матч уже был, всё посчитали) - # - no_game (сегодня матчей вообще нет) - # -> можно лечь до завтра 00:05 - tomorrow = (now + timedelta(days=1)).replace( - hour=0, minute=5, second=0, microsecond=0 - ) - sleep_seconds = (tomorrow - now).total_seconds() - if sleep_seconds < 0: - tomorrow = (now + timedelta(days=2)).replace( - hour=0, minute=5, second=0, microsecond=0 - ) - sleep_seconds = (tomorrow - now).total_seconds() - - hours_left = int(sleep_seconds // 3600) - mins_left = int((sleep_seconds % 3600) // 60) - - logger.info( - f"Работа за день завершена. Засыпаем до {tomorrow.strftime('%d.%m %H:%M')} " - f"(~{hours_left}.{mins_left} ч)." - ) - - try: - time.sleep(sleep_seconds) - except KeyboardInterrupt: - logger.info("KeyboardInterrupt во время сна -> выходим") - stop_event.set() - break - - stop_event.set() - standings_thread.join() - logger.info("[MAIN] standings thread stopped, shutdown complete") - - -# ============================================================================ -# 9. Точка входа -# ============================================================================ - -if __name__ == "__main__": - main() diff --git a/get_data_old2.py b/get_data_old2.py new file mode 100644 index 0000000..412eeeb --- /dev/null +++ b/get_data_old2.py @@ -0,0 +1,2113 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +Шаблон: мониторинг онлайн-матча по лиге/команде +Функции: +1) Валидация тега (--league), иначе: лог + сообщение в Telegram и завершение. +2) Получение номера последнего сезона из JSON по ссылке. Ошибки -> лог + Telegram. +3) Загрузка расписания по ссылке, поиск игры на сегодня для --team. + - Если сегодня нет матча — берём последний сыгранный (если есть), иначе лог + Telegram. + - Если сегодня есть игра — запускаем поток-монитор: + * статус «онлайн?» проверяем РАЗ В МИНУТУ; + * при статусе онлайн — КАЖДУЮ СЕКУНДУ дергаем три запроса: + box-score, play-by-play, live-status. + * если не онлайн — ждём минуту до следующей проверки. +4) Ежедневная перекладка: каждый следующий день повторно проверяем расписание + для команды. Если матча нет — один раз подгружаем последний сыгранный и ждём следующего дня. + +ЗАМЕТКИ: +- Заполни URL_* и функции-экстракторы JSON под свой формат. +- Для Telegram используй переменные окружения TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID. +""" + +from __future__ import annotations +import os +import sys +import time +import json +import argparse +import logging +import pandas as pd +import logging.config +import threading +import concurrent.futures +import queue +import platform +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo +from typing import Any, Dict, List +import tempfile +from pathlib import Path +from threading import Event, Lock + +import requests + +# ========================== +# ---- НАСТРОЙКИ/КОНСТАНТЫ +# ========================== + +APP_TZ = ZoneInfo("Europe/Moscow") + +# Разрешённые теги лиг +ALLOWED_LEAGUES = { + "vtb", # Единая Лига ВТБ + "vtbyouth", # Молодежка ВТБ + "vtb-supercup", # Супер-Кубок ЕЛ ВТБ + "msl", # Высшая лига. Мужчины + "mcup", # Кубок России. Мужчины + "wpremier", # Премьер-Лига. Женщины + "wsl", # Суперлига. Женщины + "whl", # Высшая лига. Женщины + "wcup", # Кубок России. Женщины + "dubl-b", # Дюбл до 19 лет + # "pr-mezhreg-w13", # Межрегиональные соревнования до 14 лет + # добавляй свои… +} + +DEFAULT_LEAGUE = "vtb" +DEFAULT_LANG = "en" + +# URL-шаблоны (замени на реальные) +HOST = "ref.russiabasket.org" +URL_SEASON = "https://{host}/api/abc/comps/seasons?Tag={league}&Lang={lang}" # вернёт JSON со списком сезонов +URL_SCHEDULE = "https://{host}/api/abc/comps/calendar?Tag={league}&Season={season}&Lang={lang}&MaxResultCount=1000" # расписание лиги (или команды) +# Статус конкретной игры (используется для проверки "онлайн?" раз в минуту) +URL_GAME = "https://{host}/api/abc/games/game?Id={game_id}&lang={lang}" +# Быстрые запросы, когда матч онлайн (каждую секунду) +URL_BOX_SCORE = "https://{host}/api/abc/games/box-score?Id={game_id}&lang={lang}" +URL_PLAY_BY_PLAY = "https://{host}/api/abc/games/play-by-play?Id={game_id}&lang={lang}" +URL_LIVE_STATUS = "https://{host}/api/abc/games/live-status?Id={game_id}&lang={lang}" +URL_STANDINGS = "https://{host}/api/abc/comps/actual-standings?tag={league}&season={season}&lang={lang}" + +# Интервалы опроса +STATUS_CHECK_INTERVAL_SEC = 60 # проверять "онлайн?" раз в минуту +ONLINE_FETCH_INTERVAL_SEC = 1 # когда матч онлайн, дергать три запроса каждую секунду +POLL_INTERVAL_OFFLINE_SEC = 300 # резервный интервал сна при ошибках/до старта +TIMEOUT_DATA_OFF = 600 + +TELEGRAM_BOT_TOKEN = "7639240596:AAH0YtdQoWZSC-_R_EW4wKAHHNLIA0F_ARY" +# TELEGRAM_CHAT_ID = 228977654 +TELEGRAM_CHAT_ID = -4803699526 + +MYHOST = platform.node() +if not os.path.exists("logs"): + os.makedirs("logs") + +LOG_CONFIG = { + "version": 1, + "handlers": { + "telegram": { + "class": "telegram_handler.TelegramHandler", + "level": "INFO", + "token": TELEGRAM_BOT_TOKEN, + "chat_id": TELEGRAM_CHAT_ID, + "formatter": "telegram", + }, + "console": { + "class": "logging.StreamHandler", + "level": "INFO", + "formatter": "simple", + "stream": "ext://sys.stdout", + }, + "file": { + "class": "logging.FileHandler", + "level": "DEBUG", + "formatter": "simple", + "filename": f"logs/GFX_{MYHOST}.log", + "encoding": "utf-8", + }, + }, + "loggers": { + __name__: {"handlers": ["console", "file", "telegram"], "level": "DEBUG"}, + }, + "formatters": { + "telegram": { + "class": "telegram_handler.HtmlFormatter", + "format": f"%(levelname)s [{MYHOST.upper()}] %(message)s", + "use_emoji": "True", + }, + "simple": { + "class": "logging.Formatter", + "format": "%(asctime)s %(levelname)-8s %(funcName)s() - %(message)s", + "datefmt": "%d.%m.%Y %H:%M:%S", + }, + }, +} + +logging.config.dictConfig(LOG_CONFIG) +logger = logging.getLogger(__name__) +logger.handlers[2].formatter.use_emoji = True + + +# ========================== +# ---- УТИЛИТЫ +# ========================== + + +def fetch_json( + url: str, params: dict | None = None, session: requests.Session | None = None +) -> dict: + """ + GET JSON с таймаутом и внятными ошибками. + Использует переданный session для keep-alive. + """ + sess = session or requests + try: + r = sess.get(url, params=params, timeout=(3.0, 4.0)) # (connect, read) + r.raise_for_status() + return r.json() + except requests.HTTPError as he: + raise RuntimeError(f"HTTP {he.response.status_code} для {url}") from he + except requests.RequestException as re: + raise RuntimeError(f"Сетевой сбой для {url}: {re}") from re + except json.JSONDecodeError as je: + raise RuntimeError(f"Некорректный JSON на {url}: {je}") from je + + +# ========================== +# ---- ЭКСТРАКТОРЫ ИЗ JSON +# ========================== + + +def extract_last_season(data: dict) -> str: + """ + Вытаскиваем последний сезон, в списке он первый + """ + try: + seasons = data["items"] + if not seasons: + raise ValueError("пустой список сезонов") + last = seasons[0] + season_id = last["season"] + return str(season_id) + except Exception as e: + raise RuntimeError(f"Не удалось извлечь последний сезон: {e}") from e + + +def extract_team_schedule_for_season(data: dict, team_code: str) -> list[dict]: + """ + Верни список игр команды. Адаптируй ключи под реальный JSON. + Предполагаем формат игр: + { + "gameId": "12345", + "home": "BOS", + "away": "LAL", + "startTimeUTC": "2025-10-23T18:00:00Z", + "status": "finished|scheduled|inprogress" + } + """ + try: + games = data["items"] + team_games = [ + g for g in games if g.get("team1").get("name").lower() == team_code.lower() + ] + return team_games + except Exception as e: + raise RuntimeError(f"Не удалось извлечь расписание команды: {e}") from e + + +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 + + Возвращает aware-datetime в APP_TZ. + """ + g = item.get("game", {}) if "game" in item else item + + raw = g.get("defaultZoneDateTime") or g.get("scheduledTime") or g.get("startTime") + if raw: + try: + dt = datetime.fromisoformat(raw) # ISO-8601 + return dt.astimezone(APP_TZ) + except Exception as e: + raise RuntimeError(f"Ошибка парсинга ISO времени '{raw}': {e}") + + # Fallback: localDate + localTime (пример: "30.09.2025" + "19:00") + ld, lt = g.get("localDate"), g.get("localTime") + if ld and lt: + try: + naive = datetime.strptime(f"{ld} {lt}", "%d.%m.%Y %H:%M") + aware = naive.replace(tzinfo=APP_TZ) + return aware + except Exception as e: + raise RuntimeError(f"Ошибка парсинга localDate/localTime '{ld} {lt}': {e}") + + raise RuntimeError( + "Не найдено ни одного подходящего поля времени (defaultZoneDateTime/scheduledTime/startTime/localDate+localTime)." + ) + + +def extract_game_status(data: dict) -> str: + """ + Ожидаем JSON вида {"status":"inprogress|scheduled|finished"} + """ + try: + return str(data["result"]["status"]["id"]).lower() + except Exception as e: + raise RuntimeError(f"Не удалось извлечь статус матча: {e}") from e + + +# ========================== +# ---- ДОП. ЗАПРОСЫ ПРИ ОНЛАЙНЕ +# ========================== + + +def fetch_box_score( + league: str, game_id: str, lang: str, session: requests.Session | None = None +) -> dict: + url = URL_BOX_SCORE.format(host=HOST, league=league, game_id=game_id, lang=lang) + return fetch_json(url, session=session) + + +def fetch_play_by_play( + league: str, game_id: str, lang: str, session: requests.Session | None = None +) -> dict: + url = URL_PLAY_BY_PLAY.format(host=HOST, league=league, game_id=game_id, lang=lang) + return fetch_json(url, session=session) + + +def fetch_live_status( + league: str, game_id: str, lang: str, session: requests.Session | None = None +) -> dict: + url = URL_LIVE_STATUS.format(host=HOST, league=league, game_id=game_id, lang=lang) + return fetch_json(url, session=session) + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _get(d: dict | None, *path, default=None): + """Безопасно достаём вложенные ключи: _get(d, "result", "fullScore", default={})""" + cur = d or {} + for p in path: + if not isinstance(cur, dict) or p not in cur: + return default + cur = cur[p] + return cur + + +def _dedup_plays(plays: List[dict]) -> List[dict]: + """ + Удаляем дубли по стабильному идентификатору события. + Приоритет: eventId -> id -> (sequence, clock, teamId, type) + """ + seen = set() + out = [] + for ev in plays: + if not isinstance(ev, dict): + continue + key = ( + ev.get("eventId") + or ev.get("id") + or (ev.get("sequence"), ev.get("clock"), ev.get("teamId"), ev.get("type")) + ) + if key in seen: + continue + seen.add(key) + out.append(ev) + # если есть поле sequence/time — отсортируем, чтобы обработчик получал стабильный порядок + out.sort( + key=lambda e: ( + e.get("sequence") is None, + e.get("sequence"), + e.get("time") or e.get("clock"), + ) + ) + return out + + +def merge_online_payloads( + game: dict, + box_score: dict | None, + play_by_play: dict | None, + live_status: dict | None, +) -> Dict[str, Any]: + """ + Склеивает онлайн-ответы в единый компактный payload для downstream-обработки. + Ничего не знает о внутренней логике обработки — только нормализует. + """ + # исходные куски + # plays_raw: List[dict] = _get(play_by_play, "result", default=[]) or [] + # score_by_periods = _get(box_score, "result", "scoreByPeriods", default=[]) or [] + # full_score = _get(box_score, "result", "fullScore", default={}) or {} + # teams = _get(box_score, "result", "teams", default={}) or {} # если пригодится в обработчике + # players = _get(box_score, "result", "players", default=[]) or [] + + # box_score = _get(box_score, "result", "teams", default=[]) or [] + # fullScore = _get(box_score, "result", "fullScore", default="") or "" + + # # live + # live_status = _get(live_status, "result", "live_status") + # period = _get(live_status, "result", "period") + # clock = _get(live_status, "result", "clock") + # status = _get(live_status, "result", "status") # e.g., "inprogress", "ended", "scheduled" + + # нормализация/дедуп + # plays = _dedup_plays(plays_raw) + + # print(game["teams"]) + # print(box_score) + for index_team, team in enumerate(game["teams"][1:]): + box_team = box_score["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["plays"] = play_by_play.get("result", []) + game["scoreByPeriods"] = box_score["result"].get("scoreByPeriods", []) + game["fullScore"] = box_score["result"].get("fullScore", {}) + game["live_status"] = live_status["result"] + # game[""] + merged: Dict[str, Any] = { + "meta": { + "generatedAt": _now_iso(), + "sourceHints": { + "boxScoreHas": list((_get(box_score, "result") or {}).keys()), + "pbpLen": "", + }, + }, + "result": game, + } + return merged + + +# где-то в твоём коде +def process_online_update(merged: dict) -> None: + """ + Здесь — любая твоя логика: обновить JSON-файлы, пересчитать агрегаты, + уведомить подписчиков, обновить кеш и т.д. + """ + # пример: + game_id = merged["meta"]["gameId"] + print(game_id) + # ... твоя обработка ... + + +def is_already_merged(obj: dict) -> bool: + """ + Проверяем, что объект уже содержит result.plays/fullScore/scoreByPeriods. + Подходит для ответа game (исторический матч). + """ + if not isinstance(obj, dict): + return False + res = obj.get("result") or obj.get("game") or obj # подстрахуемся под разные корни + if not isinstance(res, dict): + return False + r = res.get("result", res) # иногда внутри ещё один "result" + return ( + isinstance(r, dict) + and isinstance(r.get("plays", []), list) + and isinstance(r.get("fullScore", {}), dict) + and isinstance(r.get("scoreByPeriods", []), list) + ) + + +def ensure_merged_payload( + game_or_merged: dict | None = None, + *, + box_score: dict | None = None, + play_by_play: dict | None = None, + live_status: dict | None = None, + game_meta: dict | None = None, # например {"id": ..., "league": ...} +) -> dict: + """ + 1) Если передан уже-склеенный payload (исторический матч) — нормализуем и возвращаем. + 2) Иначе склеиваем из box/pbp/live через merge_online_payloads. + """ + # 1) Уже склеено (история) — просто привести к твоему контракту {meta, ids, result} + if game_or_merged and is_already_merged(game_or_merged): + g = game_or_merged.get("result") or game_or_merged # допускаем разные корни + # print(g) + with open("temp.json", "w", encoding="utf-8") as f: + json.dump(g, f, ensure_ascii=False, indent=2) + merged = { + "meta": { + "generatedAt": _now_iso(), + "sourceHints": {"from": "game_api", "pbpLen": len(g.get("plays", []))}, + }, + "result": g, + } + return merged + + + # 2) Онлайн-ветка — склеиваем так, как у тебя уже реализовано + if box_score is not None or play_by_play is not None or live_status is not None: + base_game = game_meta or {} + out_path = Path("static") / "game.json" + with open(out_path, "r", encoding="utf-8") as file: + game = json.load(file) + base_game = game["result"] + + # print(base_game) + return merge_online_payloads(base_game, box_score, play_by_play, live_status) + # 2b) Fallback: если пришёл "game", но без plays/fullScore/scoreByPeriods — всё равно сохраним + if game_or_merged: + g = game_or_merged.get("result") or game_or_merged + return { + "meta": { + "generatedAt": _now_iso(), + "sourceHints": {"from": "game_api_raw"}, + }, + "result": g, # положим сырой ответ целиком — чтобы файл гарантированно записался + } + raise ValueError( + "ensure_merged_payload: не передан ни уже-склеенный game, ни box/pbp/live." + ) + + +def atomic_write_json(path: str | Path, data: dict, ensure_dirs: bool = True) -> None: + path = Path(path) + if ensure_dirs: + path.parent.mkdir(parents=True, exist_ok=True) + # атомарная запись: пишем во временный файл и переименовываем + with tempfile.NamedTemporaryFile( + "w", delete=False, dir=str(path.parent), encoding="utf-8" + ) as tmp: + json.dump(data, tmp, ensure_ascii=False, indent=2) + tmp.flush() + os.fsync(tmp.fileno()) + tmp_name = tmp.name + os.replace(tmp_name, path) + + +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 Referee(merged: dict, *, out_dir: str = "static") -> None: + """ + Поток, создающий JSON-файл с информацией о судьях матча. + """ + logger.info("START making json for referee") + + desired_order = [ + "Crew chief", + "Referee 1", + "Referee 2", + "Commissioner", + "Ст.судья", + "Судья 1", + "Судья 2", + "Комиссар", + ] + + try: + # Найти судей (teamNumber == 0) + team_ref = next( + (t for t in merged["result"]["teams"] if t["teamNumber"] == 0), None + ) + if not team_ref: + logger.warning("Не найдена судейская бригада в данных.") + + referees_raw = team_ref.get("starts", []) + # print(referees_raw) + referees = [] + + for r in referees_raw: + flag_code = r.get("countryId", "").lower() if r.get("countryName") else "" + referees.append( + { + "displayNumber": r.get("displayNumber", ""), + "positionName": r.get("positionName", ""), + "lastNameGFX": f"{r.get('firstName', '')} {r.get('lastName', '')}".strip(), + "secondName": r.get("secondName", ""), + "birthday": r.get("birthday", ""), + "age": r.get("age", 0), + "flag": f"https://flagicons.lipis.dev/flags/4x3/{flag_code}.svg", + } + ) + + # Сортировка по позиции + referees = sorted( + referees, + key=lambda x: ( + desired_order.index(x["positionName"]) + if x["positionName"] in desired_order + else len(desired_order) + ), + ) + out_path = Path(out_dir) / "referee.json" + atomic_write_json(out_path, referees) + logging.info("Сохранил payload: {out_path}") + + except Exception as e: + logger.error(f"Ошибка в Referee потоке: {e}", exc_info=True) + + +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 = Path(out_dir) / "scores.json" + atomic_write_json(out_path, score_by_quarter) + logging.info("Сохранил payload: {out_path}") + + except Exception as e: + logger.error(f"Ошибка в Scores_Quarter: {e}", exc_info=True) + + +def status_online_func(merged: dict, *, out_dir: str = "static") -> None: + """ + Получает онлайн-статус игры и возвращает данные + путь к PNG-фолам. + """ + try: + out_path = Path(out_dir) / "live_status.json" + + if "live_status" in merged["result"]: + status_data = merged["result"]["live_status"] + atomic_write_json(out_path, [status_data]) + else: + logger.warning("Матч не ОНЛАЙН!!!!") + atomic_write_json( + out_path, + [ + { + "foulsA": 0, + "foulsB": 0, + } + ], + ) + logging.info("Сохранил payload: {out_path}") + + except Exception as e: + logger.error(f"Ошибка в status_online_func: {e}", exc_info=True) + return None + + +def Standing_func(league, season, lang, stop_event: threading.Event, out_dir: str = "static") -> None: + logger.info("START making json for standings") + while not stop_event.is_set(): + try: + url = URL_STANDINGS.format(host=HOST, league=league, season=season, lang=lang) + data_standings = fetch_json(url) + + if data_standings and "items" in data_standings and data_standings["items"]: + standings_temp = data_standings["items"] + for item in standings_temp: + if "standings" in item and item["standings"] != []: + standings_temp = item["standings"] + df = pd.json_normalize(standings_temp) + del df["scores"] + if not df["totalWin"].isna().all(): + df["w_l"] = ( + df["totalWin"].astype(str) + + " / " + + df["totalDefeat"].astype(str) + ) + df["procent"] = df.apply( + lambda row: ( + 0 + if row["w_l"] == "0 / 0" + or row["totalGames"] == 0 + or pd.isna(row["totalWin"]) + else round( + row["totalWin"] * 100 / row["totalGames"] + + 0.000005 + ) + ), + axis=1, + ) + df["plus_minus"] = ( + df["totalGoalPlus"] - df["totalGoalMinus"] + ) + filepath = os.path.join( + out_dir, + f"standings_{league}_{item['comp']['name'].replace(' ', '_')}.json", + ) + + + df.to_json( + filepath, + orient="records", + force_ascii=False, + indent=4, + ) + logger.info("Standings data saved successfully.") + elif "playoffPairs" in item and item["playoffPairs"] != []: + standings_temp = item["playoffPairs"] + df = pd.json_normalize(standings_temp) + filepath = os.path.join( + out_dir, + f"standings_{league}_{item['comp']['name'].replace(' ', '_')}.json", + ) + df.to_json( + filepath, + orient="records", + force_ascii=False, + indent=4, + ) + logger.info("Standings data saved successfully.") + except Exception as e: + logger.warning(f"Ошибка в турнирном положении: {e}") + + stop_event.wait(TIMEOUT_DATA_OFF) + + + +# ========================== +# ---- ДОМЕННАЯ ЛОГИКА +# ========================== + + +def validate_league_or_die(league: str) -> str: + league = (league or DEFAULT_LEAGUE).lower().strip() + if league not in ALLOWED_LEAGUES: + logger.warning( + f"Неверный тег лиги: '{league}'. Допустимо: {sorted(ALLOWED_LEAGUES)}" + ) + sys.exit(2) + return league + + +def get_last_season_or_die(league: str, lang: str) -> str: + url = URL_SEASON.format(host=HOST, league=league, lang=lang) + try: + data = fetch_json(url) + season = extract_last_season(data) + logging.info(f"Последний сезон для {league}: {season}") + return season + except Exception as e: + logger.warning(f"Не получилось получить последний сезон для {league}: {e}") + sys.exit(3) + + +def get_team_schedule_or_die( + league: str, season: str, team: str, lang: str +) -> list[dict]: + url = URL_SCHEDULE.format(host=HOST, league=league, season=season, lang=lang) + try: + data = fetch_json(url) + team_games = extract_team_schedule_for_season(data, team) + if not team_games: + logger.warning(f"Для команды {team} не найдено игр в сезоне {season}.") + return team_games + except Exception as e: + logger.warning(f"Не получилось получить расписание {league}/{season}: {e}") + return [] + + +def pick_today_or_last_played( + team_games: list[dict], now: datetime +) -> tuple[dict | None, dict | None]: + """ + Возвращает (сегодняшняя игра, последний сыгранный матч). + """ + today = now.date() + games_sorted = sorted(team_games, key=parse_game_start_dt) + today_game = None + last_played = None + + for g in games_sorted: + start = parse_game_start_dt(g) + status = g.get("game", {}).get("gameStatus", "").lower() + if start.date() == today and today_game is None: + today_game = g + if start <= now and status == "resultconfirmed": + last_played = g + return today_game, last_played + + +def is_game_online(league: str, game_id: str, lang: str) -> str: + """ + Возвращает статус: inprogress|scheduled|finished (или то, что твой API даёт). + """ + url = URL_GAME.format(host=HOST, league=league, game_id=game_id, lang=lang) + data = fetch_json(url) + + out_path = Path("static") / "game.json" + atomic_write_json(out_path, data) + + return extract_game_status(data) + + +class PostProcessor: + def __init__(self): + self.q = queue.Queue(maxsize=1) + self._t = threading.Thread(target=self._worker, daemon=True) + self._stop = threading.Event() + self._t.start() + + def submit(self, merged): + # кладём только «последний» payload + try: + # если очередь занята, выкидываем старое задание + while True: + self.q.get_nowait() + except queue.Empty: + pass + # не блокируем: если за эту миллисекунду кто-то положил — просто заменим в следующий раз + try: + self.q.put_nowait(merged) + except queue.Full: + pass + + def _worker(self): + while not self._stop.is_set(): + merged = self.q.get() + try: + Json_Team_Generation(merged, out_dir="static", who="team1") + Json_Team_Generation(merged, out_dir="static", who="team2") + Team_Both_Stat(merged, out_dir="static") + Referee(merged, out_dir="static") + Scores_Quarter(merged, out_dir="static") + status_online_func(merged, out_dir="static") + except Exception as e: + logging.exception(f"Postproc failed: {e}") + + def stop(self): + self._stop.set() + + +class OnlinePoller: + def __init__( + self, league: str, game_id: str, lang: str, on_update: callable | None = None + ): + self.league = league + self.game_id = game_id + self.lang = lang + self._stop_event = threading.Event() + self._thread: threading.Thread | None = None + self._log = logging.info("start") + self._on_update = on_update + self._post = PostProcessor() + + # 1) Постоянная сессия и пул соединений + self._session = requests.Session() + retry = Retry( + total=2, + connect=2, + read=2, + backoff_factor=0.1, + status_forcelist=(502, 503, 504), + allowed_methods=frozenset(["GET"]), + ) + adapter = HTTPAdapter(pool_connections=1, pool_maxsize=10, max_retries=retry) + self._session.mount("http://", adapter) + self._session.mount("https://", adapter) + self._session.headers.update( + { + "Connection": "keep-alive", + "Accept": "application/json, */*", + "Accept-Encoding": "gzip, deflate, br", + "User-Agent": "game-watcher/1.0", + } + ) + + def stop(self): + if self._thread and self._thread.is_alive(): + self._stop_event.set() + self._thread.join(timeout=2) + # self._log.info(f"Онлайн-поллер для игры {self.game_id} остановлен.") + self._thread = None + try: + self._session.close() + except Exception: + pass + try: + self._post.stop() + except Exception: + pass + + def _run(self): + # Исполнитель для параллельных GET + with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool: + while not self._stop_event.is_set(): + started = time.perf_counter() + try: + futures = [ + pool.submit( + fetch_box_score, + self.league, + self.game_id, + self.lang, + self._session, + ), + pool.submit( + fetch_play_by_play, + self.league, + self.game_id, + self.lang, + self._session, + ), + pool.submit( + fetch_live_status, + self.league, + self.game_id, + self.lang, + self._session, + ), + ] + bs, pbp, ls = (f.result() for f in futures) + merged = ensure_merged_payload( + None, + box_score=bs, + play_by_play=pbp, + live_status=ls, + game_meta={"id": self.game_id, "league": self.league}, + ) + # print(merged) + # внешний коллбек, если задан + if self._on_update: + self._on_update(merged) + + # твоя общая обработка + сохранение + self._post.submit(merged) + + logger.debug( + "Обновления online: box-score(%s keys), pbp(%s keys), live-status(%s keys)", + len(bs) if isinstance(bs, dict) else "—", + len(pbp) if isinstance(pbp, dict) else "—", + len(ls) if isinstance(ls, dict) else "—", + ) + except Exception as e: + logger.warning(f"Сбой online-поллера для игры {self.game_id}: {e}") + # лёгкая задержка после ошибки, но не «наказание» на целую секунду + time.sleep(0.2) + + # Точное выдерживание частоты: «1 цикл в секунду» + elapsed = time.perf_counter() - started + rest = ONLINE_FETCH_INTERVAL_SEC - elapsed + if rest > 0: + # спим только остаток + self._stop_event.wait(rest) + + def start(self): + if self._thread and self._thread.is_alive(): + return + self._stop_event.clear() + self._thread = threading.Thread( + target=self._run, + name=f"poller-{self.game_id}", + daemon=True, + ) + self._thread.start() + self._log.info(f"Онлайн-поллер для игры {self.game_id} запущен.") + + +def monitor_game_loop( + league: str, game_id: str, lang: str, stop_event: threading.Event +) -> None: + logger.info(f"Старт мониторинга игры {game_id} ({league}).") + poller = OnlinePoller(league, game_id, lang) + was_online = False + + while not stop_event.is_set(): + try: + status = is_game_online(league, game_id, lang) + # print(status) + is_online = status in {"scheduled", "online"} + is_finished = status in {"resultconfirmed", "result"} + + if is_finished: + logger.info(f"Матч {game_id} завершён.\nОстанавливаем мониторинг.") + break + + if is_online and not was_online: + logger.info( + f"Матч {game_id} перешёл в онлайн.\nЗапускаем быстрый опрос (1 сек)." + ) + poller.start() + elif not is_online and was_online: + logger.info( + f"Матч {game_id} вышел из онлайна (или ещё не стартовал).\nОстанавливаем быстрый опрос." + ) + poller.stop() + + was_online = is_online + + # Проверяем статус снова через минуту + stop_event.wait(STATUS_CHECK_INTERVAL_SEC) + + except Exception as e: + logger.warning(f"Сбой проверки статуса матча {game_id}: {e}") + # При ошибке — не дергаем быстро, подождём немного и повторим + stop_event.wait(POLL_INTERVAL_OFFLINE_SEC) + + # Гарантированно остановим быстрый опрос при завершении + poller.stop() + logger.info(f"Мониторинг матча {game_id} остановлен.") + + +def next_midnight_local(now: datetime) -> datetime: + tomorrow = (now + timedelta(days=1)).date() + return datetime.combine(tomorrow, datetime.min.time(), tzinfo=APP_TZ) + timedelta( + minutes=5 + ) + # return now + timedelta(seconds=30) + + +def daily_rollover_loop( + league: str, + team: str, + lang: str, + season_getter, + schedule_getter, + monitor_mgr, + stop_event: threading.Event, +): + """ + Каждый день в ~00:05 по Europe/Moscow: + - узнаём актуальный сезон + - заново тянем расписание + - выбираем сегодняшнюю игру или последний сыгранный + - при наличии сегодняшней игры — перезапускаем монитор на неё + """ + while not stop_event.is_set(): + now = datetime.now(APP_TZ) + wakeup_at = next_midnight_local(now) + seconds = (wakeup_at - now).total_seconds() + logger.info( + # f"Ежедневка: проснусь {datetime.fromisoformat(wakeup_at.isoformat())} (через {int(seconds)} сек)." + f"Ежедневная проверка матча:\nпроснусь {wakeup_at.strftime('%Y-%m-%d %H:%M:%S')} (через {int(seconds)} сек)." + ) + if stop_event.wait(seconds): + break + + # Выполняем ежедневную проверку + try: + season = season_getter(league, lang) + games = schedule_getter(league, season, team, lang) + if not games: + logger.info( + f"Ежедневная проверка:\nу {team} нет игр в расписании сезона {season}." + ) + continue + + today_game, last_played = pick_today_or_last_played( + games, datetime.now(APP_TZ) + ) + if today_game: + gid = today_game["game"]["id"] + logger.info( + f"Сегодня у {team} есть игра: gameID={gid}. \nПерезапуск мониторинга." + ) + monitor_mgr.restart(gid, lang) + elif last_played: + gid = last_played["game"]["id"] + logger.info( + f"Сегодня у {team} нет игры. \nПоследняя сыгранная: gameID={gid}.\nМониторинг НЕ запускаем." + ) + else: + logger.info(f"Сегодня у {team} нет игры и нет предыдущих сыгранных.") + except Exception as e: + logger.warning(f"Ошибка ежедневной проверки: {e}") + + +class MonitorManager: + """ + Управляет потоком мониторинга, чтобы можно было + безопасно перезапускать на новый gameId. + """ + + def __init__(self, league: str): + self.league = league + self._thread: threading.Thread | None = None + self._stop_event = threading.Event() + self._lock = threading.Lock() + + def restart(self, game_id: str, lang: str): + with self._lock: + self.stop() + self._stop_event = threading.Event() + self._thread = threading.Thread( + target=monitor_game_loop, + args=(self.league, game_id, lang, self._stop_event), + name=f"monitor-{game_id}", + daemon=True, + ) + self._thread.start() + + def stop(self): + if self._thread and self._thread.is_alive(): + self._stop_event.set() + self._thread.join(timeout=5) + self._thread = None + + +# ========================== +# ---- MAIN +# ========================== + + +def main(): + # global MYHOST + parser = argparse.ArgumentParser(description="Game watcher") + parser.add_argument("--league", type=str, default=DEFAULT_LEAGUE, help="тег лиги") + parser.add_argument( + "--team", type=str, required=True, help="код/тег команды (например, BOS)" + ) + parser.add_argument("--lang", type=str, default="en", help="язык получения данных") + parser.add_argument( + "--log-level", type=str, default="INFO", help="DEBUG|INFO|WARNING|ERROR" + ) + args = parser.parse_args() + print(args) + + # logger.info(f"Запуск программы пользователем: {MYHOST}") + logger.info( + f"Запуск с параметрами:\nleague={args.league}\nteam={args.team}\nlang={args.lang}" + ) + + league = validate_league_or_die(args.league) + team = args.team.lower() + + # 1) Узнать последний сезон + season = get_last_season_or_die(league, args.lang) + + + + + + # 2) Получить расписание для команды + team_games = get_team_schedule_or_die(league, season, team, args.lang) + if not team_games: + logger.warning("Расписание пустое — работа завершена.") + sys.exit(4) + + # 3) Найти сегодняшнюю или последнюю сыгранную игру + now = datetime.now(APP_TZ) + today_game, last_played = pick_today_or_last_played(team_games, now) + + monitor_mgr = MonitorManager(league=league) + + if today_game: + # В исходном расписании предполагалось наличие game.id + game_id = today_game["game"]["id"] + logger.info( + f"Сегодня у {team} есть игра: gameID={game_id}.\nЗапускаю мониторинг." + ) + monitor_mgr.restart(game_id, args.lang) + else: + if last_played: + game_id = last_played["game"]["id"] + try: + url = URL_GAME.format( + host=HOST, league=league, game_id=game_id, lang=args.lang + ) + game_json = fetch_json(url) + merged = ensure_merged_payload( + game_json, + game_meta={ + "id": game_json.get("result", {}).get("gameId"), + "league": args.league, + }, + ) + Json_Team_Generation(merged, out_dir="static", who="team1") + Json_Team_Generation(merged, out_dir="static", who="team2") + Team_Both_Stat(merged, out_dir="static") + Referee(merged, out_dir="static") + Scores_Quarter(merged, out_dir="static") + status_online_func(merged, out_dir="static") + # print(merged) + logger.info( + f"Сегодня у {team} нет игры.\nПоследняя сыгранная: gameID={game_id}.\nМониторинг не запускаю." + ) + except Exception as e: + logging.exception( + f"Оффлайн-сохранение для gameID={game_id}\nупало: {e}" + ) + else: + logger.info(f"Сегодня у {team} нет игры и нет предыдущих сыгранных.") + + # 4) Ежедневная перекладка расписания + stop_event = threading.Event() + rollover_thread = threading.Thread( + target=daily_rollover_loop, + args=( + league, + team, + args.lang, + get_last_season_or_die, + get_team_schedule_or_die, + monitor_mgr, + stop_event, + ), + name="daily-rollover", + daemon=True, + ) + rollover_thread.start() + + # 1.1) турнирная таблица + threads = [ + threading.Thread( + target=Standing_func, + args=(league, season, args.lang, stop_event), + name="standings",)] + + for t in threads: + t.start() + logger.debug(f"Поток {t.name} запущен.") + + # Держим главный поток живым + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + logger.info("Завершение по Ctrl+C…") + stop_event.set() + for t in threads: + t.join() + logger.debug(f"Поток {t.name} завершён.") + finally: + stop_event.set() + monitor_mgr.stop() + rollover_thread.join(timeout=5) + logger.info("Остановлено.") + + +if __name__ == "__main__": + main()