from fastapi import FastAPI from fastapi.responses import Response, HTMLResponse from fastapi import HTTPException from fastapi import Request from contextlib import asynccontextmanager import requests import threading import time import queue import argparse import uvicorn import os import pandas as pd import json from datetime import datetime, time as dtime, timedelta from fastapi.responses import Response import logging import logging.config import platform # передадим параметры через аргументы или глобальные переменные parser = argparse.ArgumentParser() 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() MYHOST = platform.node() if not os.path.exists("logs"): os.makedirs("logs") telegram_bot_token = "7639240596:AAH0YtdQoWZSC-_R_EW4wKAHHNLIA0F_ARY" # TELEGRAM_CHAT_ID = 228977654 telegram_chat_id = -4803699526 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 LEAGUE = args.league TEAM = args.team LANG = args.lang HOST = "https://deti.russiabasket.org" STATUS = False GAME_ID = None SEASON = None GAME_START_DT = None # datetime начала матча (локальная из календаря) GAME_TODAY = False # флаг: игра сегодня GAME_SOON = False # флаг: игра сегодня и скоро (<1 часа) URLS = { "seasons": "{host}/api/abc/comps/seasons?Tag={league}", "actual-standings": "{host}/api/abc/comps/actual-standings?tag={league}&season={season}&lang={lang}", "calendar": "{host}/api/abc/comps/calendar?Tag={league}&Season={season}&Lang={lang}&MaxResultCount=1000", "game": "{host}/api/abc/games/game?Id={game_id}&Lang={lang}", "pregame": "{host}/api/abc/games/pregame?tag={league}&season={season}&id={game_id}&lang={lang}", "pregame-full-stats": "{host}/api/abc/games/pregame-full-stats?tag={league}&season={season}&id={game_id}&lang={lang}", "live-status": "{host}/api/abc/games/live-status?id={game_id}", "box-score": "{host}/api/abc/games/box-score?id={game_id}", "play-by-play": "{host}/api/abc/games/play-by-play?id={game_id}", } def start_offline_threads(season, game_id): """Запускаем редкие запросы, когда матча нет или он уже сыгран.""" global threads_offline, CURRENT_THREADS_MODE, stop_event_offline # если уже работаем в офлайне — не дублируем if CURRENT_THREADS_MODE == "offline": return # на всякий случай гасим лайв stop_live_threads() stop_event_offline.clear() threads_offline = [ threading.Thread( target=get_data_from_API, args=( "game", URLS["game"].format(host=HOST, game_id=game_id, lang=LANG), 1, # раз в секунду/реже stop_event_offline, ), daemon=True, ) ] for t in threads_offline: t.start() CURRENT_THREADS_MODE = "offline" logger.info("[threads] OFFLINE threads started") def start_live_threads(season, game_id): """Запускаем частые онлайн-запросы, когда матч идёт/вот-вот.""" global threads_live, CURRENT_THREADS_MODE, stop_event_live # если уже в лайве — не дублируем if CURRENT_THREADS_MODE == "live": return # на всякий случай гасим офлайн stop_offline_threads() stop_event_live.clear() threads_live = [ threading.Thread( target=get_data_from_API, args=( "pregame", URLS["pregame"].format( host=HOST, league=LEAGUE, season=season, game_id=game_id, lang=LANG ), 0.0016667, stop_event_live, ), daemon=True, ), threading.Thread( target=get_data_from_API, args=( "pregame-full-stats", URLS["pregame-full-stats"].format( host=HOST, league=LEAGUE, season=season, game_id=game_id, lang=LANG ), 0.0016667, stop_event_live, ), daemon=True, ), threading.Thread( target=get_data_from_API, args=( "actual-standings", URLS["actual-standings"].format( host=HOST, league=LEAGUE, season=season, lang=LANG ), 0.0016667, stop_event_live, ), daemon=True, ), threading.Thread( target=get_data_from_API, args=( "game", URLS["game"].format(host=HOST, game_id=game_id, lang=LANG), 0.00016, # часто stop_event_live, ), daemon=True, ), threading.Thread( target=get_data_from_API, args=( "live-status", URLS["live-status"].format(host=HOST, game_id=game_id), 1, stop_event_live, ), daemon=True, ), threading.Thread( target=get_data_from_API, args=( "box-score", URLS["box-score"].format(host=HOST, game_id=game_id), 1, stop_event_live, ), daemon=True, ), threading.Thread( target=get_data_from_API, args=( "play-by-play", URLS["play-by-play"].format(host=HOST, game_id=game_id), 1, stop_event_live, ), daemon=True, ), ] for t in threads_live: t.start() CURRENT_THREADS_MODE = "live" logger.info("[threads] LIVE threads started") def stop_live_threads(): """Гасим только live-треды.""" global threads_live if not threads_live: return stop_event_live.set() for t in threads_live: t.join(timeout=1) threads_live = [] logger.info("[threads] LIVE threads stopped") def stop_offline_threads(): """Гасим только offline-треды.""" global threads_offline if not threads_offline: return stop_event_offline.set() for t in threads_offline: t.join(timeout=1) threads_offline = [] logger.info("[threads] OFFLINE threads stopped") # общая очередь results_q = queue.Queue() # тут будем хранить последние данные latest_data = {} # событие для остановки потоков stop_event = threading.Event() # отдельные события для разных наборов потоков stop_event_live = threading.Event() stop_event_offline = threading.Event() # чтобы из consumer можно было их гасить threads_live = [] threads_offline = [] # какой режим сейчас запущен: "live" / "offline" / None CURRENT_THREADS_MODE = None # Функция запускаемая в потоках def get_data_from_API( name: str, url: str, quantity: float, stop_event: threading.Event ): if quantity <= 0: raise ValueError("quantity must be > 0") sleep_time = 1.0 / quantity # это и есть "раз в N секунд" while not stop_event.is_set(): start = time.time() try: value = requests.get(url, timeout=5).json() except Exception as ex: value = {"error": str(ex)} ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] results_q.put({"source": name, "ts": ts, "data": value}) logger.debug(f"[{ts}] name: {name}, status: {value.get('status', 'no-status')}") # сколько уже заняло elapsed = time.time() - start # сколько надо доспать, чтобы в сумме вышла нужная частота to_sleep = sleep_time - elapsed if to_sleep > 0: time.sleep(to_sleep) # если запрос занял дольше — просто сразу следующую итерацию # Получение результатов из всех запущенных потоков def results_consumer(): while not stop_event.is_set(): try: msg = results_q.get(timeout=0.5) except queue.Empty: continue try: source = msg.get("source") payload = msg.get("data") or {} # универсальный статус (может не быть) incoming_status = payload.get("status") # может быть None # 1) play-by-play if "play-by-play" in source: game = latest_data.get("game") # если игра уже нормальная — приклеиваем плейи if ( game and isinstance(game, dict) and "data" in game and "result" in game["data"] ): # у pbp тоже может не быть data/result if "result" in payload: game["data"]["result"]["plays"] = payload["result"] # а вот статус у play-by-play иногда просто "no-status" latest_data[source] = { "ts": msg["ts"], "data": incoming_status if incoming_status is not None else payload, } # 2) box-score elif "box-score" in source: game = latest_data.get("game") if ( game and "data" in game and "result" in game["data"] and "teams" in game["data"]["result"] and "result" in payload and "teams" in payload["result"] ): # обновляем команды game["data"]["result"]["game"]["fullScore"] = payload["result"][ "fullScore" ] for team in game["data"]["result"]["teams"]: if team["teamNumber"] != 0: box_team = [ t for t in payload["result"]["teams"] if t["teamNumber"] == team["teamNumber"] ] if not box_team: print("ERROR: box-score team not found") continue box_team = box_team[0] for player in team["starts"]: box_player = [ p for p in box_team["starts"] if p["startNum"] == player["startNum"] ] if box_player: player["stats"] = box_player[0] team["total"] = box_team["total"] team["startTotal"] = box_team["startTotal"] team["benchTotal"] = box_team["benchTotal"] team["maxLeading"] = box_team["maxLeading"] team["pointsInRow"] = box_team["pointsInRow"] team["maxPointsInRow"] = box_team["maxPointsInRow"] # в любом случае сохраняем сам факт, что box-score пришёл latest_data[source] = { "ts": msg["ts"], "data": incoming_status if incoming_status is not None else payload, } elif "live-status" in source: latest_data[source] = { "ts": msg["ts"], "data": payload, } try: ls_data = payload.get("result") or payload raw_ls_status = ( ls_data.get("status") or ls_data.get("gameStatus") or ls_data.get("state") ) if raw_ls_status: raw_ls_status_low = str(raw_ls_status).lower() finished_markers = [ "finished", "result", "resultconfirmed", "ended", "final", "game over", ] # матч ЗАКОНЧЕН → гасим live и включаем offline if any(m in raw_ls_status_low for m in finished_markers): logger.info("[status] match finished → switch to OFFLINE") if ( GAME_START_DT and GAME_START_DT.date() == datetime.now().date() ): globals()["STATUS"] = "finished_today" else: globals()["STATUS"] = "finished" stop_live_threads() start_offline_threads(SEASON, GAME_ID) # матч СТАЛ онлайном (напр., из Scheduled → Online) elif ( "online" in raw_ls_status_low or "live" in raw_ls_status_low ): if globals().get("STATUS") not in ["live", "live_soon"]: logger.info( "[status] match became LIVE → switch to LIVE threads" ) globals()["STATUS"] = "live" start_live_threads(SEASON, GAME_ID) except Exception as e: logger.warning( "results_consumer: live-status postprocess error:", e ) else: if source == "game": has_game_already = "game" in latest_data # есть ли в ответе ПОЛНАЯ структура is_full = ( "data" in payload and isinstance(payload["data"], dict) and "result" in payload["data"] ) if is_full: # полный game — всегда кладём latest_data["game"] = { "ts": msg["ts"], "data": payload, } else: # game неполный if not has_game_already: # 👉 раньше game вообще не было — лучше положить хоть что-то latest_data["game"] = { "ts": msg["ts"], "data": payload, } else: # 👉 уже есть какой-то game — неполным НЕ затираем logger.debug( "results_consumer: got partial game, keeping previous one" ) # и обязательно continue/return из этого elif/if else: latest_data[source] = { "ts": msg["ts"], "data": payload, } continue # ... остальная обработка ... except Exception as e: logger.warning("results_consumer error:", repr(e)) continue def get_items(data: dict) -> list: """ Мелкий хелпер: берём первый список в ответе API. Многие ручки отдают {"result":[...]} или {"seasons":[...]}. Если находим список — возвращаем его. Если нет — возвращаем None (значит, нужно брать весь dict). """ for k, v in data.items(): if isinstance(v, list): return data[k] return None def pick_game_for_team(calendar_json): """ Возвращает: game_id: str | None game_dt: datetime | None is_today: bool cal_status: str | None # Scheduled / Online / Result / ResultConfirmed Логика: 1. если в календаре есть игра КОМАНДЫ на сегодня — берём ЕЁ и возвращаем её gameStatus 2. иначе — берём последнюю прошедшую и тоже возвращаем её gameStatus """ items = get_items(calendar_json) if not items: return None, None, False, None today = datetime.now().date() # 1) сначала — сегодняшняя for game in reversed(items): if game["team1"]["name"].lower() != TEAM.lower(): continue gdt = extract_game_datetime(game) gdate = ( gdt.date() if gdt else datetime.strptime(game["game"]["localDate"], "%d.%m.%Y").date() ) if gdate == today: cal_status = game["game"].get("gameStatus") return game["game"]["id"], gdt, True, cal_status # 2) если на сегодня нет — берём последнюю прошедшую last_id = None last_dt = None last_status = None for game in reversed(items): if game["team1"]["name"].lower() != TEAM.lower(): continue gdt = extract_game_datetime(game) gdate = ( gdt.date() if gdt else datetime.strptime(game["game"]["localDate"], "%d.%m.%Y").date() ) if gdate <= today: last_id = game["game"]["id"] last_dt = gdt last_status = game["game"].get("gameStatus") break return last_id, last_dt, False, last_status def extract_game_datetime(game_item: dict) -> datetime | None: """ Из элемента календаря достаём datetime матча. В календаре есть localDate и часто localTime. Если localTime нет — берём 00:00. """ try: date_str = game_item["game"]["localDate"] # '31.10.2025' dt_date = datetime.strptime(date_str, "%d.%m.%Y").date() time_str = game_item["game"].get("localTime") # '19:30' if time_str: hh, mm = map(int, time_str.split(":")) dt_time = dtime(hour=hh, minute=mm) else: dt_time = dtime(hour=0, minute=0) return datetime.combine(dt_date, dt_time) except Exception: return None @asynccontextmanager async def lifespan(app: FastAPI): global STATUS, GAME_ID, SEASON, GAME_START_DT, GAME_TODAY, GAME_SOON # 1. проверяем API: seasons try: seasons_resp = requests.get( URLS["seasons"].format(host=HOST, league=LEAGUE) ).json() season = seasons_resp["items"][0]["season"] except Exception: now = datetime.now() if now.month > 9: season = now.year + 1 else: season = now.year logger.info("не удалось получить последний сезон.") SEASON = season # 2. берём календарь try: calendar = requests.get( URLS["calendar"].format(host=HOST, league=LEAGUE, season=season, lang=LANG) ).json() except Exception as ex: logger.error(f"не получилось проверить работу API. код ошибки: {ex}") # тут можно вообще не запускать сервер, но оставим как есть calendar = None # 3. определяем игру game_id, game_dt, is_today, cal_status = ( pick_game_for_team(calendar) if calendar else (None, None, False, None) ) GAME_ID = game_id GAME_START_DT = game_dt GAME_TODAY = is_today logger.info( f"\nЛига: {LEAGUE}\nСезон: {season}\nКоманда: {TEAM}\nGame ID: {game_id}" ) # 4. запускаем "длинные" потоки (они у тебя и так всегда) thread_result_consumer = threading.Thread( target=results_consumer, daemon=True, ) thread_result_consumer.start() # 5. Подготовим онлайн и офлайн наборы (как у тебя) threads_live = [ threading.Thread( target=get_data_from_API, args=( "pregame", URLS["pregame"].format( host=HOST, league=LEAGUE, season=season, game_id=game_id, lang=LANG ), 0.0016667, stop_event, ), daemon=True, ), threading.Thread( target=get_data_from_API, args=( "pregame-full-stats", URLS["pregame-full-stats"].format( host=HOST, league=LEAGUE, season=season, game_id=game_id, lang=LANG ), 0.0016667, stop_event, ), daemon=True, ), threading.Thread( target=get_data_from_API, args=( "actual-standings", URLS["actual-standings"].format( host=HOST, league=LEAGUE, season=season, lang=LANG ), 0.0016667, stop_event, ), daemon=True, ), threading.Thread( target=get_data_from_API, args=( "game", URLS["game"].format(host=HOST, game_id=game_id, lang=LANG), 0.00016, stop_event, ), daemon=True, ), threading.Thread( target=get_data_from_API, args=( "live-status", URLS["live-status"].format(host=HOST, game_id=game_id), 1, stop_event, ), daemon=True, ), threading.Thread( target=get_data_from_API, args=( "box-score", URLS["box-score"].format(host=HOST, game_id=game_id), 1, stop_event, ), daemon=True, ), threading.Thread( target=get_data_from_API, args=( "play-by-play", URLS["play-by-play"].format(host=HOST, game_id=game_id), 1, stop_event, ), daemon=True, ), ] threads_offline = [ threading.Thread( target=get_data_from_API, args=( "game", URLS["game"].format(host=HOST, game_id=game_id, lang=LANG), 1, # реже stop_event, ), daemon=True, ) ] # 5. решаем, что запускать if not is_today: STATUS = "no_game_today" start_offline_threads(SEASON, GAME_ID) else: # игра сегодня if cal_status is None: STATUS = "today_not_started" start_offline_threads(SEASON, GAME_ID) elif cal_status == "Scheduled": if game_dt: delta = game_dt - datetime.now() if delta <= timedelta(hours=1): STATUS = "live_soon" start_live_threads(SEASON, GAME_ID) else: STATUS = "today_not_started" start_offline_threads(SEASON, GAME_ID) else: STATUS = "today_not_started" start_offline_threads(SEASON, GAME_ID) elif cal_status == "Online": STATUS = "live" start_live_threads(SEASON, GAME_ID) elif cal_status in ["Result", "ResultConfirmed"]: STATUS = "finished_today" start_offline_threads(SEASON, GAME_ID) else: STATUS = "today_not_started" start_offline_threads(SEASON, GAME_ID) yield # -------- shutdown -------- stop_event.set() # завершить consumer stop_live_threads() stop_offline_threads() thread_result_consumer.join(timeout=1) app = FastAPI(lifespan=lifespan) 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" @app.get("/team1") async def team1(): game = get_latest_game_safe() if not game: raise HTTPException(status_code=503, detail="game data not ready") return await team("team1") @app.get("/team2") async def team2(): game = get_latest_game_safe() if not game: raise HTTPException(status_code=503, detail="game data not ready") return await team("team2") @app.get("/top_team1") async def top_team1(): data = await team("team1") return await top_sorted_team(data) @app.get("/top_team2") async def top_team2(): data = await team("team2") return await top_sorted_team(data) @app.get("/started_team1") async def started_team1(): data = await team("team1") return await started_team(data) @app.get("/started_team2") async def started_team2(): data = await team("team2") return await started_team(data) @app.get("/game") async def game(): return latest_data["game"] @app.get("/status") async def status(request: Request): def color_for_status(status_value: str) -> str: """Подбор цвета статуса в HEX""" status_value = str(status_value).lower() if status_value in ["ok", "success", "live", "live_soon", "online"]: return "#00FF00" # зелёный elif status_value in ["scheduled", "today_not_started", "upcoming"]: return "#FFFF00" # жёлтый elif status_value in [ "result", "resultconfirmed", "finished", "finished_today", ]: return "#FF0000" # красный elif status_value in ["no_game_today", "unknown", "none"]: return "#FFFFFF" # белый else: return "#808080" # серый (неизвестный статус) # ✳️ сортируем latest_data в нужном порядке sort_order = ["game", "live-status", "box-score", "play-by-play"] sorted_keys = [k for k in sort_order if k in latest_data] + sorted( [k for k in latest_data if k not in sort_order] ) data = { "league": LEAGUE, "team": TEAM, "game_id": GAME_ID, "game_status": STATUS, "statuses": [ { "name": TEAM, "status": STATUS, "ts": ( GAME_START_DT.strftime("%Y-%m-%d %H:%M") if GAME_START_DT else "N/A" ), "link": LEAGUE, "color": color_for_status(STATUS), } ] + [ { "name": item, "status": ( latest_data[item]["data"]["status"] if isinstance(latest_data[item]["data"], dict) and "status" in latest_data[item]["data"] else latest_data[item]["data"] ), "ts": latest_data[item]["ts"], "link": URLS[item].format( host=HOST, league=LEAGUE, season=SEASON, lang=LANG, game_id=GAME_ID, ), "color": color_for_status( latest_data[item]["data"]["status"] if isinstance(latest_data[item]["data"], dict) and "status" in latest_data[item]["data"] else latest_data[item]["data"] ), } for item in sorted_keys # ← используем отсортированный порядок ], } accept = request.headers.get("accept", "") if "text/html" in accept: status_raw = str(STATUS).lower() if status_raw in ["live"]: gs_class = "live" gs_text = "🟢 LIVE" elif status_raw in ["live_soon"]: gs_class = "live" gs_text = "🟢 GAME TODAY (soon)" elif status_raw == "today_not_started": gs_class = "upcoming" gs_text = "🟡 Game today, not started" elif status_raw in ["finished_today", "finished"]: gs_class = "finished" gs_text = "🔴 Game finished" elif status_raw == "no_game_today": gs_class = "unknown" gs_text = "⚪ No game today" else: gs_class = "unknown" gs_text = "⚪ UNKNOWN" html = f"""
League: {LEAGUE}
Team: {TEAM}
Game ID: {GAME_ID}
Game Status: {gs_text}
| Name | Status | Timestamp | Link |
|---|---|---|---|
| {s["name"]} | {status_text} | {s["ts"]} | {s["link"]} |