diff --git a/get_data.py b/get_data.py index 235405d..8264c09 100644 --- a/get_data.py +++ b/get_data.py @@ -9,13 +9,14 @@ import time import queue import argparse import uvicorn -from pprint import pprint 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 # передадим параметры через аргументы или глобальные переменные @@ -26,16 +27,72 @@ 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://pro.russiabasket.org" +HOST = "https://deti.russiabasket.org" STATUS = False GAME_ID = None SEASON = None -GAME_START_DT = None # datetime начала матча (локальная из календаря) -GAME_TODAY = False # флаг: игра сегодня -GAME_SOON = False # флаг: игра сегодня и скоро (<1 часа) +GAME_START_DT = None # datetime начала матча (локальная из календаря) +GAME_TODAY = False # флаг: игра сегодня +GAME_SOON = False # флаг: игра сегодня и скоро (<1 часа) URLS = { "seasons": "{host}/api/abc/comps/seasons?Tag={league}", @@ -50,6 +107,158 @@ URLS = { } +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" + print("[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" + print("[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 = [] + print("[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 = [] + print("[threads] OFFLINE threads stopped") + + # общая очередь results_q = queue.Queue() # тут будем хранить последние данные @@ -57,6 +266,17 @@ 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( @@ -133,7 +353,9 @@ def results_consumer(): and "teams" in payload["result"] ): # обновляем команды - game["data"]["result"]["game"]["fullScore"] = payload["result"]["fullScore"] + game["data"]["result"]["game"]["fullScore"] = payload["result"][ + "fullScore" + ] for team in game["data"]["result"]["teams"]: if team["teamNumber"] != 0: box_team = [ @@ -169,17 +391,13 @@ def results_consumer(): } elif "live-status" in source: - # просто сохраним, как и остальные latest_data[source] = { "ts": msg["ts"], "data": payload, } - # попытка ДОПОЛНИТЕЛЬНО обновить глобальный STATUS по live-status try: - ls_data = payload.get("result") or payload # иногда сразу result - # тут нужно посмотреть, какое именно поле у тебя в live-status - # допустим, там есть что-то вроде "status" или "gameStatus" + ls_data = payload.get("result") or payload raw_ls_status = ( ls_data.get("status") or ls_data.get("gameStatus") @@ -187,27 +405,42 @@ def results_consumer(): ) if raw_ls_status: - raw_ls_status = str(raw_ls_status).lower() + raw_ls_status_low = str(raw_ls_status).lower() - # варианты, которые считаем "матч окончен" finished_markers = [ "finished", "result", "resultconfirmed", "ended", - "game over", "final", + "game over", ] - if any(m in raw_ls_status for m in finished_markers): - # перезатираем глобальный статус — он более актуальный - # если матч сегодня — делаем finished_today, иначе просто finished - from datetime import datetime - - if GAME_START_DT and GAME_START_DT.date() == datetime.now().date(): + # матч ЗАКОНЧЕН → гасим live и включаем offline + if any(m in raw_ls_status_low for m in finished_markers): + print("[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"]: + print( + "[status] match became LIVE → switch to LIVE threads" + ) + globals()["STATUS"] = "live" + start_live_threads(SEASON, GAME_ID) + except Exception as e: print("results_consumer: live-status postprocess error:", e) @@ -238,7 +471,9 @@ def results_consumer(): } else: # 👉 уже есть какой-то game — неполным НЕ затираем - print("results_consumer: got partial game, keeping previous one") + print( + "results_consumer: got partial game, keeping previous one" + ) # и обязательно continue/return из этого elif/if else: @@ -253,6 +488,7 @@ def results_consumer(): print("results_consumer error:", repr(e)) continue + def get_items(data: dict) -> list: """ Мелкий хелпер: берём первый список в ответе API. @@ -268,6 +504,7 @@ def get_items(data: dict) -> list: from datetime import datetime + def pick_game_for_team(calendar_json): """ Возвращает: @@ -292,7 +529,11 @@ def pick_game_for_team(calendar_json): continue gdt = extract_game_datetime(game) - gdate = gdt.date() if gdt else datetime.strptime(game["game"]["localDate"], "%d.%m.%Y").date() + gdate = ( + gdt.date() + if gdt + else datetime.strptime(game["game"]["localDate"], "%d.%m.%Y").date() + ) if gdate == today: cal_status = game["game"].get("gameStatus") @@ -308,7 +549,11 @@ def pick_game_for_team(calendar_json): continue gdt = extract_game_datetime(game) - gdate = gdt.date() if gdt else datetime.strptime(game["game"]["localDate"], "%d.%m.%Y").date() + gdate = ( + gdt.date() + if gdt + else datetime.strptime(game["game"]["localDate"], "%d.%m.%Y").date() + ) if gdate <= today: last_id = game["game"]["id"] @@ -319,7 +564,6 @@ def pick_game_for_team(calendar_json): return last_id, last_dt, False, last_status - def extract_game_datetime(game_item: dict) -> datetime | None: """ Из элемента календаря достаём datetime матча. @@ -336,7 +580,7 @@ def extract_game_datetime(game_item: dict) -> datetime | None: dt_time = dtime(hour=0, minute=0) return datetime.combine(dt_date, dt_time) except Exception: - return None + return None @asynccontextmanager @@ -345,7 +589,9 @@ async def lifespan(app: FastAPI): # 1. проверяем API: seasons try: - seasons_resp = requests.get(URLS["seasons"].format(host=HOST, league=LEAGUE)).json() + seasons_resp = requests.get( + URLS["seasons"].format(host=HOST, league=LEAGUE) + ).json() season = seasons_resp["items"][0]["season"] except Exception: now = datetime.now() @@ -367,7 +613,9 @@ async def lifespan(app: FastAPI): 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_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 @@ -381,7 +629,7 @@ async def lifespan(app: FastAPI): # 5. Подготовим онлайн и офлайн наборы (как у тебя) threads_live = [ - threading.Thread( + threading.Thread( target=get_data_from_API, args=( "pregame", @@ -417,7 +665,6 @@ async def lifespan(app: FastAPI): ), daemon=True, ), - threading.Thread( target=get_data_from_API, args=( @@ -472,66 +719,43 @@ async def lifespan(app: FastAPI): ) ] - # 6. Решение: сегодня / не сегодня + # 5. решаем, что запускать if not is_today: - # ИГРЫ СЕГОДНЯ НЕТ → крутим только офлайн STATUS = "no_game_today" - for t in threads_offline: - t.start() + start_offline_threads(SEASON, GAME_ID) else: # игра сегодня if cal_status is None: - # нет статуса в календаре — считаем, что ещё не началась STATUS = "today_not_started" - for t in threads_offline: - t.start() + 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" - GAME_SOON = True - for t in threads_live: - t.start() + start_live_threads(SEASON, GAME_ID) else: - # ещё далеко → офлайн, но говорим что сегодня STATUS = "today_not_started" - for t in threads_offline: - t.start() - # и можно повесить будильник, как раньше + start_offline_threads(SEASON, GAME_ID) else: - # нет времени — просто офлайн, но сегодня STATUS = "today_not_started" - for t in threads_offline: - t.start() + start_offline_threads(SEASON, GAME_ID) elif cal_status == "Online": - # матч идёт → сразу онлайн STATUS = "live" - GAME_SOON = False - for t in threads_live: - t.start() + start_live_threads(SEASON, GAME_ID) elif cal_status in ["Result", "ResultConfirmed"]: - # матч уже сыгран, но дата всё ещё сегодня STATUS = "finished_today" - for t in threads_offline: - t.start() + start_offline_threads(SEASON, GAME_ID) else: - # неизвестный статус — безопасный вариант STATUS = "today_not_started" - for t in threads_offline: - t.start() + start_offline_threads(SEASON, GAME_ID) yield - + # -------- shutdown -------- - stop_event.set() - # офлайн/онлайн ты можешь не делить тут, но оставлю - stop_event.set() - for t in threads_live + threads_offline: - t.join(timeout=1) + stop_event.set() # завершить consumer + stop_live_threads() + stop_offline_threads() thread_result_consumer.join(timeout=1) @@ -554,7 +778,7 @@ def format_time(seconds: float | int) -> str: return "0:00" -@app.get("/team1.json") +@app.get("/team1") async def team1(): game = get_latest_game_safe() if not game: @@ -562,7 +786,7 @@ async def team1(): return await team("team1") -@app.get("/team2.json") +@app.get("/team2") async def team2(): game = get_latest_game_safe() if not game: @@ -570,36 +794,36 @@ async def team2(): return await team("team2") -@app.get("/top_team1.json") +@app.get("/top_team1") async def top_team1(): data = await team("team1") return await top_sorted_team(data) -@app.get("/top_team2.json") +@app.get("/top_team2") async def top_team2(): data = await team("team2") return await top_sorted_team(data) -@app.get("/started_team1.json") +@app.get("/started_team1") async def started_team1(): data = await team("team1") return await started_team(data) -@app.get("/started_team2.json") +@app.get("/started_team2") async def started_team2(): data = await team("team2") return await started_team(data) -@app.get("/game.json") +@app.get("/game") async def game(): return latest_data["game"] -@app.get("/status.json") +@app.get("/status") async def status(request: Request): def color_for_status(status_value: str) -> str: """Подбор цвета статуса в HEX""" @@ -608,13 +832,25 @@ async def status(request: Request): return "#00FF00" # зелёный elif status_value in ["scheduled", "today_not_started", "upcoming"]: return "#FFFF00" # жёлтый - elif status_value in ["result", "resultconfirmed", "finished", "finished_today"]: + 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, @@ -624,16 +860,20 @@ async def status(request: Request): { "name": TEAM, "status": STATUS, - "ts": GAME_START_DT.strftime("%Y-%m-%d %H:%M") if GAME_START_DT else "N/A", + "ts": ( + GAME_START_DT.strftime("%Y-%m-%d %H:%M") if GAME_START_DT else "N/A" + ), "link": LEAGUE, - "color": color_for_status(STATUS) # ← добавлено + "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"] + if isinstance(latest_data[item]["data"], dict) + and "status" in latest_data[item]["data"] else latest_data[item]["data"] ), "ts": latest_data[item]["ts"], @@ -646,14 +886,15 @@ async def status(request: Request): ), "color": color_for_status( latest_data[item]["data"]["status"] - if isinstance(latest_data[item]["data"], dict) and "status" in latest_data[item]["data"] + if isinstance(latest_data[item]["data"], dict) + and "status" in latest_data[item]["data"] else latest_data[item]["data"] - ) # ← добавлено + ), } - for item in latest_data + for item in sorted_keys # ← используем отсортированный порядок ], } - + accept = request.headers.get("accept", "") if "text/html" in accept: status_raw = str(STATUS).lower() @@ -675,7 +916,7 @@ async def status(request: Request): else: gs_class = "unknown" gs_text = "⚪ UNKNOWN" - + html = f""" @@ -732,11 +973,19 @@ async def status(request: Request): for s in data["statuses"]: status_text = str(s["status"]).strip().lower() - if any(x in status_text for x in ["ok", "success", "live", "live_soon", "online"]): + if any( + x in status_text + for x in ["ok", "success", "live", "live_soon", "online"] + ): color_class = "ok" - elif any(x in status_text for x in ["scheduled", "today_not_started", "upcoming"]): + elif any( + x in status_text for x in ["scheduled", "today_not_started", "upcoming"] + ): color_class = "warn" - elif any(x in status_text for x in ["result", "resultconfirmed", "finished", "finished_today"]): + elif any( + x in status_text + for x in ["result", "resultconfirmed", "finished", "finished_today"] + ): color_class = "fail" else: color_class = "unknown" @@ -762,7 +1011,8 @@ async def status(request: Request): response.headers["Refresh"] = "1" return response -@app.get("/scores.json") + +@app.get("/scores") async def scores(): game = get_latest_game_safe() if not game: @@ -803,7 +1053,6 @@ async def scores(): return score_by_quarter - async def top_sorted_team(data): top_sorted_team = sorted( (p for p in data if p.get("startRole") in ["Player", ""]), @@ -869,7 +1118,9 @@ async def team(who: str): # нормализуем доступ к данным game_data = game["data"] if "data" in game else game - result = game_data["result"] # здесь уже безопасно, мы проверили в get_latest_game_safe + result = game_data[ + "result" + ] # здесь уже безопасно, мы проверили в get_latest_game_safe # в result ожидаем "teams" teams = result.get("teams") @@ -1069,6 +1320,7 @@ async def team(who: str): return sorted_team + async def started_team(data): started_team = sorted( ( @@ -1285,7 +1537,7 @@ stat_name_list = [ ] -@app.get("/team_stats.json") +@app.get("/team_stats") async def team_stats(): teams = latest_data["game"]["data"]["result"]["teams"] plays = latest_data["game"]["data"]["result"]["plays"] @@ -1339,7 +1591,7 @@ async def team_stats(): return result_json -@app.get("/referee.json") +@app.get("/referee") async def referee(): desired_order = [ "Crew chief", @@ -1391,7 +1643,7 @@ async def referee(): return referees -@app.get("/team_comparison.json") +@app.get("/team_comparison") async def team_comparison(): if STATUS not in ["no_game_today", "finished_today"]: data = latest_data["pregame"]["data"]["result"] @@ -1467,9 +1719,7 @@ async def team_comparison(): return [{"Данных о сравнении команд нет!"}] - - -@app.get("/standings.json") +@app.get("/standings") async def regular_standings(): data = latest_data["actual-standings"]["data"]["items"] for item in data: @@ -1540,7 +1790,7 @@ async def regular_standings(): return standings_payload -@app.get("/live_status.json") +@app.get("/live_status") async def live_status(): # если матч реально идёт/вот-вот — пытаемся отдать то, что есть if STATUS in ["live", "live_soon"]: @@ -1563,9 +1813,7 @@ async def live_status(): # 2) если это просто строка статуса ("ok" / "no-status" / "error") if isinstance(raw, str): - return [{ - "status": raw - }] + return [{"status": raw}] # fallback return [{"foulsA": 0, "foulsB": 0}] @@ -1575,4 +1823,4 @@ async def live_status(): if __name__ == "__main__": - uvicorn.run("get_data:app", host="0.0.0.0", port=8000, reload=True) + uvicorn.run("get_data:app", host="0.0.0.0", port=8000, reload=True, log_level="critical")