турнирка

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 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_<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. Суточный цикл: находим игру, следим в лайве, потом уходим спать
# ============================================================================