diff --git a/.gitignore b/.gitignore index af74f68..3d0938f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ /static/* get_data_new copy 2.py get_data_new copy.py -temp.json \ No newline at end of file +temp.json +get_data_new copy 3.py \ No newline at end of file diff --git a/get_data_new.py b/get_data_new.py index ed9fbab..0462fcd 100644 --- a/get_data_new.py +++ b/get_data_new.py @@ -450,11 +450,29 @@ def is_game_live(game_obj: dict) -> bool: if status in ("resultconfirmed", "finished", "result"): return False - if status in ("scheduled", "notstarted", "draft"): + 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 ("scheduled", "notstarted", "draft"): + return "upcoming" + # всё остальное считаем лайвом + return "live" + + # ============================================================================ # 6. Лайв-петля: опрос API и поток рендера # ============================================================================ @@ -738,7 +756,6 @@ def Referee(merged: dict, *, out_dir: str = "static") -> None: logger.warning("Не найдена судейская бригада в данных.") referees_raw = team_ref.get("starts", []) - # print(referees_raw) referees = [] for r in referees_raw: @@ -777,7 +794,7 @@ 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 @@ -811,10 +828,13 @@ def Play_By_Play(data: dict) -> None: return # Получение текущего времени игры - json_live_status = data["result"]["live_status"] if "result" in data and "live_status" in data["result"] else None + json_live_status = ( + data["result"]["live_status"] + if "result" in data and "live_status" in data["result"] + else None + ) last_event = plays[-1] - # if not json_live_status or json_live_status.get("message") == "Not Found": if json_live_status is None: period = last_event.get("period", 1) second = 0 @@ -825,10 +845,6 @@ def Play_By_Play(data: dict) -> None: # Создание DataFrame из событий df = pd.DataFrame(plays[::-1]) - # Преобразование для лиги 3x3 - # if "3x3" in LEAGUE: - # df["play"].replace({2: 1, 3: 2}, inplace=True) - df_goals = df[df["play"].isin([1, 2, 3])].copy() if df_goals.empty: logger.debug("нет данных о голах в play-by-play") @@ -846,8 +862,7 @@ def Play_By_Play(data: dict) -> None: 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 + 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"] @@ -859,9 +874,7 @@ def Play_By_Play(data: dict) -> None: ) 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 - ) + lambda x: (f"{x // 60}:{str(x % 60).zfill(2)}" if isinstance(x, int) else x) ) # Текстовые поля @@ -873,7 +886,7 @@ def Play_By_Play(data: dict) -> None: else team2_name ) - # ✅ Правильный порядок счёта в зависимости от команды + # правильный порядок счёта в зависимости от команды if team == team1_name: score = f"{s1}-{s2}" else: @@ -921,9 +934,7 @@ def Play_By_Play(data: dict) -> None: # Порядок колонок main_cols = ["text", "text_time"] - all_cols = main_cols + [ - col for col in df_goals.columns if col not in main_cols - ] + all_cols = main_cols + [col for col in df_goals.columns if col not in main_cols] df_goals = df_goals[all_cols] # Сохранение JSON @@ -937,7 +948,6 @@ def Play_By_Play(data: dict) -> None: logger.error(f"Ошибка в Play_By_Play: {e}", exc_info=True) - def render_once_after_game( session: requests.Session, league: str, @@ -962,7 +972,7 @@ def render_once_after_game( """ try: logger.info(f"[RENDER_ONCE] Fetching final game snapshot for game_id={game_id}") - # === 1. один запрос к API (ручка "game") === + # один запрос к API (ручка "game") state = fetch_api_data( session, "game", @@ -971,7 +981,6 @@ def render_once_after_game( lang=lang, ) - # === 3. прогнать вычисления как в render_loop === Team_Both_Stat(state) Json_Team_Generation(state, who="team1") Json_Team_Generation(state, who="team2") @@ -979,7 +988,6 @@ def render_once_after_game( Referee(state) Play_By_Play(state) - # === 4. live_status и общий state === atomic_write_json(state["result"], out_name) logger.info("[RENDER_ONCE] финальные json сохранены успешно") @@ -1054,7 +1062,6 @@ def Json_Team_Generation( for item in starts: stats = item.get("stats") or {} - # маппинг одной строки игрока row = { "id": item.get("personId") or "", "num": item.get("displayNumber"), @@ -1290,7 +1297,6 @@ def time_outs_func(data_pbp: List[dict]) -> Tuple[str, int, str, int]: elif period < 5: count = sum(1 for t in timeout_list if 3 <= t.get("period", 0) <= period) quarter = "2nd half" - # в концовке 4-й четверти лимит может ужиматься if period == 4 and sec >= 4800 and count in (0, 1): timeout_max = 2 else: @@ -1418,14 +1424,12 @@ def add_new_team_stat( } ) - # всё -> строки (UI не должен думать о типах) for k in data: data[k] = str(data[k]) return data -# Статическая таблица "как назвать метрику" stat_name_list = [ ("points", "Очки", "points"), ("pt-1", "Штрафные", "free throws"), @@ -1469,13 +1473,6 @@ stat_name_list = [ def Team_Both_Stat(merged: dict) -> None: """ Формирует сводку по двум командам и пишет её в static/team_stats.json. - - Делает: - - считает таймауты для обеих команд, - - считает средний возраст / рост, - - считает очки старт / скамейка, - - добавляет проценты попаданий, подборы и т.д., - - мапит имена метрик на удобные подписи. """ logger.info("START making json for team statistics") @@ -1483,7 +1480,6 @@ def Team_Both_Stat(merged: dict) -> None: teams = merged["result"]["teams"] plays = merged["result"].get("plays", []) - # Разделяем команды по teamNumber team_1 = next((t for t in teams if t["teamNumber"] == 1), None) team_2 = next((t for t in teams if t["teamNumber"] == 2), None) @@ -1491,17 +1487,14 @@ def Team_Both_Stat(merged: dict) -> None: 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 агрегаты total_1 = add_new_team_stat( team_1["total"], avg_age_1, @@ -1519,7 +1512,6 @@ def Team_Both_Stat(merged: dict) -> None: timeout_left2, ) - # Готовим список пар "метрика -> команда1 vs команда2" result_json = [] for key in total_1: val1 = total_1[key] @@ -1552,10 +1544,6 @@ def Team_Both_Stat(merged: dict) -> None: def Scores_Quarter(merged: dict) -> None: """ Пишет счёт по четвертям и овертаймам в static/scores.json. - - Логика: - - если есть game.result.game.fullScore -> парсим "XX:YY,AA:BB,..." - - иначе используем scoreByPeriods из box-score """ logger.info("START making json for scores quarter") @@ -1566,7 +1554,6 @@ def Scores_Quarter(merged: dict) -> None: full_score_str = merged.get("result", {}).get("game", {}).get("fullScore", "") if full_score_str: - # пример: "19:15,20:22,18:18,25:10" full_score_list = full_score_str.split(",") for i, score_str in enumerate(full_score_list[: len(score_by_quarter)]): parts = score_str.split(":") @@ -1600,41 +1587,25 @@ def Standing_func( ) -> None: """ Фоновый поток с турнирной таблицей (standings). - - Что делает: - - Периодически (не чаще, чем interval для "standings" в URLS) тянет /standings - для лиги+сезона. - - Для каждой подтаблицы (regular season, playoffs и т.д.) нормализует данные, - досчитывает полезные колонки (W/L, %) и сохраняет в - static/standings__.json - - Останавливается, когда поднят stop_event. - - Почему отдельный поток? - - standings нам нужна даже во время лайва игры, но не каждую секунду. - - Она не должна блокировать рендер и не должна блокировать poll_game_live. """ logger.info("[STANDINGS_THREAD] start standings loop") - # когда мы последний раз успешно обновили standings last_call_ts = 0 json_seasons = fetch_api_data( session, "seasons", host=HOST, league=league, lang=lang ) season = json_seasons[0]["season"] - # как часто вообще можно дёргать standings 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: - # тянем свежие данные standings тем же способом, что в get_data_API data_standings = fetch_api_data( session, "standings", @@ -1644,17 +1615,11 @@ def Standing_func( lang=lang, ) - # fetch_api_data для standings вернёт либо: - # - dict с "items": [...], либо - # - сам массив items (если get_items нашёл список) - # Мы хотим привести к единому формату, как было в твоём коде. if not data_standings: logger.debug("[STANDINGS_THREAD] standings empty") - # не обновляем last_call_ts, чтобы через секунду попытаться снова time.sleep(1) continue - # Если data_standings оказался списком, приведём к виду {"items": [...]}: if isinstance(data_standings, list): items = data_standings else: @@ -1662,26 +1627,21 @@ def Standing_func( if not items: logger.debug("[STANDINGS_THREAD] no items in standings") - last_call_ts = now # запрос был успешным, но пустым + last_call_ts = now continue - # Обрабатываем каждый "item" внутри standings: for item in items: comp = item.get("comp", {}) - comp_name = (comp.get("name") or "unknown_comp").replace(" ", "_") + comp_name = (comp.get("name") or "unknown_comp").replace(" ", "_").replace("|", "") - # 1) обычная таблица регулярки if item.get("standings"): standings_rows = item["standings"] - # pandas нормализация df = pd.json_normalize(standings_rows) - # убираем поле 'scores', если есть if "scores" in df.columns: df = df.drop(columns=["scores"]) - # добавляем w_l, procent, plus_minus если есть нужные столбцы if ( "totalWin" in df.columns and "totalDefeat" in df.columns @@ -1689,43 +1649,43 @@ def Standing_func( and "totalGoalPlus" in df.columns and "totalGoalMinus" in df.columns ): - # W / L - df["w_l"] = ( - df["totalWin"].fillna(0).astype(int).astype(str) - + " / " - + df["totalDefeat"].fillna(0).astype(int).astype(str) - ) + 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) - if ( - pd.isna(win) - or pd.isna(games) - or games == 0 - or (row["w_l"] == "0 / 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) - # +/- по очкам - df["plus_minus"] = df["totalGoalPlus"].fillna(0).astype( - int - ) - df["totalGoalMinus"].fillna(0).astype(int) + 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(standings_payload, filename, out_dir) - logger.info( - f"[STANDINGS_THREAD] сохранил {filename}.json" - ) + logger.info(f"[STANDINGS_THREAD] сохранил {filename}.json") - # 2) плейофф-пары (playoffPairs) elif item.get("playoffPairs"): playoff_rows = item["playoffPairs"] df = pd.json_normalize(playoff_rows) @@ -1738,17 +1698,14 @@ def Standing_func( f"[STANDINGS_THREAD] saved {filename}.json (playoffPairs, {len(standings_payload)} rows)" ) - # если ни standings ни playoffPairs — просто пропускаем этот блок else: continue - # если всё прошло без исключения — фиксируем время удачного апдейта last_call_ts = now except Exception as e: logger.warning(f"[STANDINGS_THREAD] ошибка в турнирном положении: {e}") - # не жрём CPU впустую time.sleep(1) logger.info("[STANDINGS_THREAD] stop standings loop") @@ -1765,22 +1722,15 @@ def get_data_API( team: str, lang: str, stop_event: threading.Event, -) -> None: +) -> str: """ - Один "дневной прогон" логики: - 1. Узнать текущий сезон - 2. Обновить standings и calendar - 3. Найти игру для нашей команды сегодня (today_game) или последнюю законченную (last_played) - 4. Если есть last_played и нет игры сегодня: - - забрать /game по last_played - - один раз сгенерировать финальные JSON (render_once_after_game) - 5. Если есть игра сегодня: - - забрать /game по today_game - - если статус игры live: - · запустить live-петлю (run_live_loop) с рендером в реальном времени - иначе (игра уже финальная, не live): - · один раз сгенерировать финальные JSON (render_once_after_game) - 6. Если нет ничего -> просто логируем и выходим + Один "дневной прогон" логики. + Возвращает day_state: + - "upcoming" -> матч сегодня (домашний), но ещё не начался + - "live_done" -> был live_loop и завершился + - "finished_now" -> матч уже завершён, отрендерили финал + - "no_game" -> сегодня игры нет вообще + - "error" -> что-то упало по дороге """ # 1. сезоны json_seasons = fetch_api_data( @@ -1788,7 +1738,7 @@ def get_data_API( ) if not json_seasons: logger.error("Не удалось получить список сезонов") - return + return "error" season = json_seasons[0]["season"] @@ -1811,59 +1761,90 @@ def get_data_API( ) if not json_calendar: logger.error("Не удалось получить список матчей") - return + return "error" - # 3. какая игра нас интересует? + # 3. определяем игру today_game, last_played = get_game_id(json_calendar, team) + print(today_game, last_played) - # 4. уже сыграли матч, а сегодня не играем + # Ветка А: есть завершённая игра, но сегодня нет матча 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 + return "finished_now" - # 5. матч сегодня есть + # Ветка Б: матч сегодня есть (для домашней команды) if today_game: game_id = today_game["game"]["id"] logger.info(f"Онлайн матч id={game_id}") - # Всегда обновляем /game прямо сейчас + # Обновляем api_* файлы сразу fetch_api_data(session, "game", host=HOST, game_id=game_id, lang=lang) - - # и эти ручки тоже сразу дергаем, чтобы у нас были свежие api_*.json 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) - fetch_api_data(session, "live-status", host=HOST, game_id=game_id) + live_status_raw = fetch_api_data( + session, "live-status", host=HOST, game_id=game_id + ) - # если матч идёт — запускаем live поток, он сам будет рендерить в цикле - if is_game_live(today_game["game"]): + # Определяем состояние матча + 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("live thread spawned, waiting for it to finish...") + logger.info("[get_data_API] live thread spawned, waiting for it to finish") try: t.join() except KeyboardInterrupt: - logger.info("KeyboardInterrupt while waiting live thread -> stop_event") + logger.info( + "[get_data_API] KeyboardInterrupt while waiting live thread -> stop_event" + ) stop_event.set() t.join() - logger.info("live thread finished") + logger.info("[get_data_API] live thread finished") + return "live_done" - else: - # матч сегодня, но он уже финальный (resultconfirmed / finished) - # значит просто один раз считаем все json-ы + if phase == "upcoming": + # матч сегодня, но ещё не начался (Scheduled / NotStarted / Draft) + # не генерим финал, не спим до завтра + logger.info( + f"Матч {game_id} сегодня, но ещё не начался (status={effective_status}). Ждём старт." + ) + return "upcoming" + + if phase == "finished": + # матч уже закончен → однократно пререндерили всё и можем спать render_once_after_game(session, league, season, game_id, lang) + return "finished_now" - return + # на всякий случай (если API дал что-то новое) + logger.info( + f"[get_data_API] Неожиданная фаза '{phase}', status={effective_status}. Считаем как 'upcoming'." + ) + return "upcoming" - # 6. ничего подходящего вообще + # Ветка В: нет матча сегодня, нет последнего завершённого logger.info("Для этой команды игр сегодня нет и нет завершённой последней игры.") + return "no_game" def main(): @@ -1873,14 +1854,9 @@ def main(): parser.add_argument("--lang", default="en") args = parser.parse_args() - # Один общий stop_event на всё приложение. stop_event = threading.Event() - # Одна сессия для standings-потока. - # Её достаточно, потому что standings не требует суперчастого обновления. standings_session = create_session() - - # Запускаем standings-поток навсегда (пока процесс жив). standings_thread = threading.Thread( target=Standing_func, args=(standings_session, args.league, None, args.lang, stop_event), @@ -1889,20 +1865,37 @@ def main(): standings_thread.start() logger.info("[MAIN] standings thread started (global)") - # Основной дневной цикл. while True: session = create_session() try: - get_data_API(session, args.league, args.team, args.lang, stop_event) + 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" - # спим до завтра 00:05 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 ) @@ -1913,9 +1906,12 @@ def main(): ) 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"(~{round(sleep_seconds/3600, 2)} ч)." + f"(~{hours_left}.{mins_left} ч)." ) try: @@ -1925,14 +1921,10 @@ def main(): stop_event.set() break - # Выход из while True → стопаем standings-поток stop_event.set() standings_thread.join() logger.info("[MAIN] standings thread stopped, shutdown complete") - # идём на новую итерацию while True - # (новая сессия / новый stop_event создаются в начале цикла) - # ============================================================================ # 9. Точка входа diff --git a/visual.py b/visual.py index 509550c..8130314 100644 --- a/visual.py +++ b/visual.py @@ -439,7 +439,7 @@ cached_referee = st.session_state.get("referee") league_tag = None if isinstance(cached_game_online, dict): league_tag = (cached_game_online.get("league") or {}).get("tag") - comp_name = (cached_game_online.get("comp") or {}).get("name").replace(" ", "_") + comp_name = (cached_game_online.get("comp") or {}).get("name").replace(" ", "_").replace("|", "") if league_tag: load_data_from_json(f"standings_{league_tag}_{comp_name}") cached_standings = ( @@ -1866,7 +1866,7 @@ try: except (FileNotFoundError, json.JSONDecodeError): play_type_id = [] -teams_section = cached_game_online.get("teams") or {} +teams_section = (cached_game_online or {}).get("teams") or {} # Если teams_section — список (например, [{"starts": [...]}, {...}]) if isinstance(teams_section, list): if len(teams_section) >= 2: @@ -1925,7 +1925,7 @@ def get_event_time(row): return None -teams_section = cached_game_online.get("teams") or {} +teams_section = (cached_game_online or {}).get("teams") or {} # Если teams_section — список (например, [{"starts": [...]}, {...}]) if isinstance(teams_section, list): @@ -1959,9 +1959,9 @@ list_fullname = [None] + [ if x.get("startRole") == "Player" ] - +plays = plays if 'plays' in locals() else [] with tab_pbp: - # plays = ((cached_game_online or {}).get("result") or {}).get("plays") or [] + plays = ((cached_game_online or {}).get("result") or {}).get("plays") or [] if plays: temp_data_pbp = pd.DataFrame(plays) col1_pbp, col2_pbp = tab_pbp.columns((3, 4))