From 48c5f552ec95a7bb2b0742cf15e1902263ce1ac6 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: Mon, 27 Oct 2025 19:44:45 +0300 Subject: [PATCH] =?UTF-8?q?=D1=82=D1=83=D1=80=D0=BD=D0=B8=D1=80=D0=BA?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- get_data_new.py | 202 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 179 insertions(+), 23 deletions(-) diff --git a/get_data_new.py b/get_data_new.py index f944380..1baa00a 100644 --- a/get_data_new.py +++ b/get_data_new.py @@ -9,6 +9,7 @@ import logging.config from datetime import datetime, timedelta, timezone from zoneinfo import ZoneInfo from typing import Any, Dict, List, Tuple, Optional +import pandas as pd import requests from requests.adapters import HTTPAdapter @@ -669,29 +670,14 @@ def run_live_loop( lang: str, game_meta: dict, stop_event: threading.Event, -) -> None: - """ - Оркестратор матча. - - Делает: - - создаёт отдельную сессию для лайва, - - стартует поток рендера, - - запускает poll_game_live (опрос live API, запись api_*.json), - - ждёт, пока матч не кончится, - - по окончании поднимает stop_event, - - джойнит поток рендера. - - Это запускается в отдельном Thread из get_data_API(). - """ +): logger.info( - f"[LIVE_THREAD] start live loop for game_id={game_id} " - f"(league={league}, season={season})" + f"[LIVE_THREAD] start live loop for game_id={game_id} (league={league}, season={season})" ) - # отдельная сессия только для лайва session = create_session() - # поток рендера (он не демон — мы хотим дождаться финальной записи JSON) + # поток рендера render_thread = threading.Thread( target=render_loop, args=(stop_event,), @@ -700,6 +686,15 @@ def run_live_loop( render_thread.start() logger.info("[LIVE_THREAD] render thread spawned") + # поток standings + standings_thread = threading.Thread( + target=Standing_func, + args=(session, league, season, lang, stop_event), + daemon=False, + ) + standings_thread.start() + logger.info("[LIVE_THREAD] standings thread spawned") + try: poll_game_live( session=session, @@ -713,13 +708,13 @@ def run_live_loop( except Exception as e: logger.exception(f"[LIVE_THREAD] crash in live loop for game_id={game_id}: {e}") finally: - # В любом случае (матч кончился, KeyboardInterrupt, ошибка) — - # говорим рендеру завершаться и дожидаемся. stop_event.set() - logger.info(f"[LIVE_THREAD] stopping render thread for game_id={game_id}") - render_thread.join() - logger.info(f"[LIVE_THREAD] stop live loop for game_id={game_id}") + logger.info(f"[LIVE_THREAD] stopping worker threads for game_id={game_id}") + render_thread.join() + standings_thread.join() + + logger.info(f"[LIVE_THREAD] stop live loop for game_id={game_id}") # ============================================================================ # 7. Постобработка статистики для вывода @@ -1322,6 +1317,167 @@ def Scores_Quarter(merged: dict) -> None: 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). + + Что делает: + - Периодически (не чаще, чем 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 + + # как часто вообще можно дёргать 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", + host=HOST, + league=league, + season=season, + 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: + items = data_standings.get("items") or [] + + if not items: + logger.debug("[STANDINGS_THREAD] no items in standings") + last_call_ts = now # запрос был успешным, но пустым + continue + + # Обрабатываем каждый "item" внутри standings: + for item in items: + comp = item.get("comp", {}) + comp_name = (comp.get("name") or "unknown_comp").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 + and "totalGames" in df.columns + 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) + ) + + # % побед + 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") + ): + 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) + ) + + # готовим питоновский список словарей для атомарной записи + 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] saved {filename}.json ({len(standings_payload)} rows)" + ) + + # 2) плейофф-пары (playoffPairs) + 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(standings_payload, filename, out_dir) + logger.info( + 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") + + # ============================================================================ # 8. Суточный цикл: находим игру, следим в лайве, потом уходим спать # ============================================================================