турнирка

This commit is contained in:
2025-10-27 19:44:45 +03:00
parent b0511d51d3
commit 48c5f552ec

View File

@@ -9,6 +9,7 @@ import logging.config
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from typing import Any, Dict, List, Tuple, Optional from typing import Any, Dict, List, Tuple, Optional
import pandas as pd
import requests import requests
from requests.adapters import HTTPAdapter from requests.adapters import HTTPAdapter
@@ -669,29 +670,14 @@ def run_live_loop(
lang: str, lang: str,
game_meta: dict, game_meta: dict,
stop_event: threading.Event, stop_event: threading.Event,
) -> None: ):
"""
Оркестратор матча.
Делает:
- создаёт отдельную сессию для лайва,
- стартует поток рендера,
- запускает poll_game_live (опрос live API, запись api_*.json),
- ждёт, пока матч не кончится,
- по окончании поднимает stop_event,
- джойнит поток рендера.
Это запускается в отдельном Thread из get_data_API().
"""
logger.info( logger.info(
f"[LIVE_THREAD] start live loop for game_id={game_id} " f"[LIVE_THREAD] start live loop for game_id={game_id} (league={league}, season={season})"
f"(league={league}, season={season})"
) )
# отдельная сессия только для лайва
session = create_session() session = create_session()
# поток рендера (он не демон — мы хотим дождаться финальной записи JSON) # поток рендера
render_thread = threading.Thread( render_thread = threading.Thread(
target=render_loop, target=render_loop,
args=(stop_event,), args=(stop_event,),
@@ -700,6 +686,15 @@ def run_live_loop(
render_thread.start() render_thread.start()
logger.info("[LIVE_THREAD] render thread spawned") 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: try:
poll_game_live( poll_game_live(
session=session, session=session,
@@ -713,13 +708,13 @@ def run_live_loop(
except Exception as e: except Exception as e:
logger.exception(f"[LIVE_THREAD] crash in live loop for game_id={game_id}: {e}") logger.exception(f"[LIVE_THREAD] crash in live loop for game_id={game_id}: {e}")
finally: finally:
# В любом случае (матч кончился, KeyboardInterrupt, ошибка) —
# говорим рендеру завершаться и дожидаемся.
stop_event.set() stop_event.set()
logger.info(f"[LIVE_THREAD] stopping render thread for game_id={game_id}") logger.info(f"[LIVE_THREAD] stopping worker threads for game_id={game_id}")
render_thread.join()
logger.info(f"[LIVE_THREAD] stop live loop 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. Постобработка статистики для вывода # 7. Постобработка статистики для вывода
@@ -1322,6 +1317,167 @@ def Scores_Quarter(merged: dict) -> None:
logger.error(f"Ошибка в Scores_Quarter: {e}", exc_info=True) 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_<league>_<compName>.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. Суточный цикл: находим игру, следим в лайве, потом уходим спать # 8. Суточный цикл: находим игру, следим в лайве, потом уходим спать
# ============================================================================ # ============================================================================