обновил статусы матчей, чтобы правильнее ожидалось, когда матч сегодня

This commit is contained in:
2025-10-28 13:45:50 +03:00
parent 94d487fe88
commit 15367b05fc
3 changed files with 144 additions and 151 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@
get_data_new copy 2.py
get_data_new copy.py
temp.json
get_data_new copy 3.py

View File

@@ -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:
@@ -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_<league>_<compName>.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. Точка входа

View File

@@ -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))