import time from datetime import datetime from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry import requests import json import os import tempfile import argparse import platform import sys import logging import pandas as pd import logging.config from zoneinfo import ZoneInfo import threading from concurrent.futures import ThreadPoolExecutor, as_completed HOST = "https://ref.russiabasket.org" APP_TZ = ZoneInfo("Europe/Moscow") MYHOST = platform.node() TELEGRAM_BOT_TOKEN = "7639240596:AAH0YtdQoWZSC-_R_EW4wKAHHNLIA0F_ARY" # TELEGRAM_CHAT_ID = 228977654 TELEGRAM_CHAT_ID = -4803699526 if not os.path.exists("logs"): os.makedirs("logs") _write_lock = threading.Lock() 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": 600, # раз в 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, # каждую секунду }, ] 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 create_session() -> requests.Session: 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 get_json(session, url: str, name: str): resp = session.get(url, timeout=10) resp.raise_for_status() data = resp.json() atomic_write_json(data, f"api_{name}") return data 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 atomic_write_json(data, name: str, out_dir: str = "static"): """ Сохраняет data в static/.json атомарно. Потокобезопасно (глобальный lock). """ os.makedirs(out_dir, exist_ok=True) filename = os.path.join(out_dir, f"{name}.json") with _write_lock: with tempfile.NamedTemporaryFile( "w", delete=False, dir=out_dir, encoding="utf-8" ) as tmp_file: json.dump(data, tmp_file, ensure_ascii=False, indent=2) tmp_file.flush() os.fsync(tmp_file.fileno()) tmp_name = tmp_file.name os.replace(tmp_name, filename) def get_items(data): for k, v in data.items(): if isinstance(v, list): return data[k] def poll_one_endpoint(session, endpoint_name, league, season, game_id, lang): """ Дёрнуть конкретный endpoint и вернуть (endpoint_name, data или None) """ 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 fetch_api_data(session, name: str, name_save: str = None, **kwargs): """ Универсальная функция для получения данных с API: 1. Собирает URL по имени ручки 2. Получает JSON 3. Возвращает основной список данных (если есть) """ 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 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 get_game_id(team_games: list[dict], team: str) -> tuple[dict | None, dict | None]: """ Принимаем все расписание и ищем для домашней команды game_id. Если сегодня нет матча, то берем game_id прошлой игры. """ 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 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: """ Пытаемся понять, идёт ли сейчас матч. game_obj ожидается как today_game["game"] (из calendar). """ status = (game_obj.get("gameStatus") or "").lower() # эвристика: # - "resultconfirmed" -> матч кончился # - "scheduled" / "notstarted" -> ещё не начался # всё остальное считаем лайвом if status in ("resultconfirmed", "finished", "final"): return False if status in ("scheduled", "notstarted", "draft"): return False return True def get_interval_by_name(name: str) -> int: """ вернуть interval из URLS по имени ручки """ for u in URLS: if u["name"] == name: return u["interval"] raise ValueError(f"interval not found for {name}") def poll_game_live(session, league: str, season: str, game_id: int, lang: str, game_meta: dict): """ Онлайн-цикл: - "game" и "pregame-fullstats" раз в 600 сек - "live-status", "box-score", "play-by-play" раз в 1 сек Всё, что надо сейчас дернуть, дергаем параллельно. Цикл выходит, когда матч перестаёт быть live. """ slow_endpoints = ["game",] #"pregame-fullstats"] fast_endpoints = ["live-status", "box-score", "play-by-play"] last_call = {} now = time.time() for name in slow_endpoints + fast_endpoints: last_call[name] = 0 # форсим первый вызов сразу # пул потоков: 5 нам хватит (у нас максимум 5 ручек одновременно) with ThreadPoolExecutor(max_workers=5) as executor: while True: 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, ) ) # будем смотреть на ответы, особенно live-status game_finished = False for fut in as_completed(futures): try: ep_name, data = fut.result() last_call[ep_name] = now # помечаем как обновлённый if ep_name == "live-status": # проверяем статус, не закончилась ли игра if isinstance(data, dict): st = ( data.get("status") or data.get("gameStatus") or "" ).lower() if st in ("resultconfirmed", "finished", "final"): logger.info( f"Game {game_id} finished by live-status -> stop loop" ) game_finished = True except Exception as e: logger.exception(f"poll endpoint error: {e}") # вторая страховка (инфо из календаря) if not is_game_live(game_meta): logger.info(f"Game {game_id} no longer live by calendar meta -> stop loop") break if game_finished: break # чуть притормозим, чтобы не жарить CPU time.sleep(0.2) logger.debug("live poll tick ok") def get_data_API2(session, league: str, team: str, lang: str): json_seasons = fetch_api_data( session, "seasons", host=HOST, league=league, lang=lang ) if not json_seasons: print("Не удалось получить список сезонов") return season = json_seasons[0]["season"] fetch_api_data( session, "standings", host=HOST, league=league, season=season, lang=lang ) json_calendar = fetch_api_data( session, "calendar", host=HOST, league=league, season=season, lang=lang ) if not json_calendar: print("Не удалось получить список матчей") return today_game, last_played = get_game_id(json_calendar, team) if last_played: game_id = last_played["game"]["id"] fetch_api_data(session, "game", host=HOST, game_id=game_id, lang=lang) elif today_game: print("ОНЛАЙН") game_id = today_game["game"]["id"] fetch_api_data(session, "game", host=HOST, game_id=game_id, lang=lang) fetch_api_data( session, "pregame", host=HOST, league=league, season=season, game_id=game_id, lang=lang, ) def get_data_API(session, league: str, team: str, lang: str): json_seasons = fetch_api_data( session, "seasons", host=HOST, league=league, lang=lang ) if not json_seasons: logger.error("Не удалось получить список сезонов") return season = json_seasons[0]["season"] # 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 today_game, last_played = get_game_id(json_calendar, team) # если есть завершённая последняя игра — просто сохраним её статические данные if last_played and not today_game: game_id = last_played["game"]["id"] logger.info(f"Последний завершённый матч id={game_id}") fetch_api_data(session, "game", host=HOST, game_id=game_id, lang=lang) return # если есть матч сегодня if today_game: game_id = today_game["game"]["id"] logger.info(f"Онлайн матч id={game_id}") # сразу получить стартовые данные перед циклом fetch_api_data(session, "game", host=HOST, game_id=game_id, lang=lang) # fetch_api_data( # session, # "pregame-fullstats", # host=HOST, # league=league, # season=season, # game_id=game_id, # lang=lang, # ) # если матч реально идёт -> запускаем быстрый опрос if is_game_live(today_game["game"]): poll_game_live( session=session, league=league, season=season, game_id=game_id, lang=lang, game_meta=today_game["game"], ) else: logger.info( "Матч ещё не стартовал, но сегодня. Просто сохранили стартовые данные." ) return logger.info("Для этой команды игр сегодня нет и нет завершённой последней игры.") 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() session = create_session() get_data_API(session, args.league, args.team, args.lang) if __name__ == "__main__": main()