Files
RFB/get_data_new.py
2025-10-27 19:26:40 +03:00

1654 lines
61 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import time
from datetime import datetime, timedelta, timezone
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
import requests
import json
import os
import tempfile
import argparse
import platform
import sys
import logging
import pandas as pd
import logging.config
from typing import Any, Dict, List
from zoneinfo import ZoneInfo
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
HOST = "https://ref.russiabasket.org"
APP_TZ = ZoneInfo("Europe/Moscow")
MYHOST = platform.node()
TELEGRAM_BOT_TOKEN = "7639240596:AAH0YtdQoWZSC-_R_EW4wKAHHNLIA0F_ARY"
# TELEGRAM_CHAT_ID = 228977654
TELEGRAM_CHAT_ID = -4803699526
if not os.path.exists("logs"):
os.makedirs("logs")
_write_lock = threading.Lock()
URLS = [
{
"name": "seasons",
"url": "{host}/api/abc/comps/seasons?Tag={league}&Lang={lang}",
"interval": 86400, # раз в сутки
},
{
"name": "standings",
"url": "{host}/api/abc/comps/standings?tag={league}&season={season}&lang={lang}",
"interval": 1800, # раз в 30 минут
},
{
"name": "calendar",
"url": "{host}/api/abc/comps/calendar?Tag={league}&Season={season}&Lang={lang}&MaxResultCount=1000",
"interval": 86400, # раз в сутки
},
{
"name": "game",
"url": "{host}/api/abc/games/game?Id={game_id}&Lang={lang}",
"interval": 600, # раз в 10 минут
},
{
"name": "pregame",
"url": "{host}/api/abc/games/pregame?tag={league}&season={season}&Id={game_id}&Lang={lang}",
"interval": 86400, # раз в сутки
},
{
"name": "pregame-fullstats",
"url": "{host}/api/abc/games/pregame-fullstats?tag={league}&season={season}&id={game_id}&lang={lang}",
"interval": 600, # раз в 10 минут
},
{
"name": "live-status",
"url": "{host}/api/abc/games/live-status?id={game_id}",
"interval": 1, # каждую секунду
},
{
"name": "box-score",
"url": "{host}/api/abc/games/box-score?Id={game_id}",
"interval": 1, # каждую секунду
},
{
"name": "play-by-play",
"url": "{host}/api/abc/games/play-by-play?Id={game_id}",
"interval": 1, # каждую секунду
},
]
LOG_CONFIG = {
"version": 1,
"handlers": {
"telegram": {
"class": "telegram_handler.TelegramHandler",
"level": "INFO",
"token": TELEGRAM_BOT_TOKEN,
"chat_id": TELEGRAM_CHAT_ID,
"formatter": "telegram",
},
"console": {
"class": "logging.StreamHandler",
"level": "INFO",
"formatter": "simple",
"stream": "ext://sys.stdout",
},
"file": {
"class": "logging.FileHandler",
"level": "DEBUG",
"formatter": "simple",
"filename": f"logs/GFX_{MYHOST}.log",
"encoding": "utf-8",
},
},
"loggers": {
__name__: {"handlers": ["console", "file", "telegram"], "level": "DEBUG"},
},
"formatters": {
"telegram": {
"class": "telegram_handler.HtmlFormatter",
"format": f"%(levelname)s [{MYHOST.upper()}] %(message)s",
"use_emoji": "True",
},
"simple": {
"class": "logging.Formatter",
"format": "%(asctime)s %(levelname)-8s %(funcName)s() - %(message)s",
"datefmt": "%d.%m.%Y %H:%M:%S",
},
},
}
logging.config.dictConfig(LOG_CONFIG)
logger = logging.getLogger(__name__)
logger.handlers[2].formatter.use_emoji = True
def create_session() -> requests.Session:
session = requests.Session()
retries = Retry(
total=3,
backoff_factor=0.5,
status_forcelist=[429, 500, 502, 503, 504],
)
session.mount("https://", HTTPAdapter(max_retries=retries))
session.headers.update(
{
"Connection": "keep-alive",
"Accept": "application/json, */*",
"Accept-Encoding": "gzip, deflate, br",
"User-Agent": "game-watcher/1.0",
}
)
return session
def get_json(session, url: str, name: str):
resp = session.get(url, timeout=10)
resp.raise_for_status()
data = resp.json()
atomic_write_json(data, f"api_{name}")
return data
def build_url(name: str, **kwargs) -> str:
"""
Собрать конечный URL по имени ручки из URLS.
Пример:
build_url("standings", host=..., league=..., season=..., lang=...)
"""
template = next((u["url"] for u in URLS if u["name"] == name), None)
if not template:
raise ValueError(f"Unknown URL name: {name}")
return template.format(**kwargs)
def atomic_write_json(data, name: str, out_dir: str = "static"):
"""
Сохраняет data в static/<name>.json атомарно.
Потокобезопасно (глобальный lock).
"""
os.makedirs(out_dir, exist_ok=True)
filename = os.path.join(out_dir, f"{name}.json")
with _write_lock:
with tempfile.NamedTemporaryFile(
"w", delete=False, dir=out_dir, encoding="utf-8"
) as tmp_file:
json.dump(data, tmp_file, ensure_ascii=False, indent=2)
tmp_file.flush()
os.fsync(tmp_file.fileno())
tmp_name = tmp_file.name
os.replace(tmp_name, filename)
def get_items(data):
for k, v in data.items():
if isinstance(v, list):
return data[k]
def poll_one_endpoint(session, endpoint_name, league, season, game_id, lang):
"""
Дёрнуть конкретный endpoint и вернуть (endpoint_name, data или None)
"""
if endpoint_name == "live-status":
data = fetch_api_data(
session,
"live-status",
host=HOST,
game_id=game_id,
)
return endpoint_name, data
if endpoint_name == "box-score":
data = fetch_api_data(
session,
"box-score",
host=HOST,
game_id=game_id,
)
return endpoint_name, data
if endpoint_name == "play-by-play":
data = fetch_api_data(
session,
"play-by-play",
host=HOST,
game_id=game_id,
)
return endpoint_name, data
if endpoint_name == "game":
data = fetch_api_data(
session,
"game",
host=HOST,
game_id=game_id,
lang=lang,
)
return endpoint_name, data
if endpoint_name == "pregame-fullstats":
data = fetch_api_data(
session,
"pregame-fullstats",
host=HOST,
league=league,
season=season,
game_id=game_id,
lang=lang,
)
return endpoint_name, data
# fallback — вдруг добавим что-то ещё
data = fetch_api_data(session, endpoint_name, host=HOST, game_id=game_id, lang=lang)
return endpoint_name, data
def fetch_api_data(session, name: str, name_save: str = None, **kwargs):
"""
Универсальная функция для получения данных с API:
1. Собирает URL по имени ручки
2. Получает JSON
3. Возвращает основной список данных (если есть)
"""
url = build_url(name, **kwargs)
try:
json_data = get_json(session, url, name_save or name)
if json_data:
items = get_items(json_data)
return items if items is not None else json_data
return None
except Exception as ex:
logger.error(f"{url} | {ex}")
def parse_game_start_dt(item: dict) -> datetime:
"""
Достаёт дату/время начала матча из объекта расписания и приводит к APP_TZ.
Приоритет полей:
1) game.defaultZoneDateTime — уже в "дефолтной зоне" лиги (например, +03:00)
2) game.scheduledTime — ISO 8601 с оффсетом (например, 2025-09-30T19:00:00+04:00)
3) game.startTime — если API когда-то его заполняет
4) (fallback) game.localDate + game.localTime — считаем, что это локальное время площадки, задаём tz=APP_TZ
Возвращает aware-datetime в APP_TZ.
"""
g = item.get("game", {}) if "game" in item else item
raw = g.get("defaultZoneDateTime") or g.get("scheduledTime") or g.get("startTime")
if raw:
try:
dt = datetime.fromisoformat(raw) # ISO-8601
return dt.astimezone(APP_TZ)
except Exception as e:
raise RuntimeError(f"Ошибка парсинга ISO времени '{raw}': {e}")
# Fallback: localDate + localTime (пример: "30.09.2025" + "19:00")
ld, lt = g.get("localDate"), g.get("localTime")
if ld and lt:
try:
naive = datetime.strptime(f"{ld} {lt}", "%d.%m.%Y %H:%M")
aware = naive.replace(tzinfo=APP_TZ)
return aware
except Exception as e:
raise RuntimeError(f"Ошибка парсинга localDate/localTime '{ld} {lt}': {e}")
raise RuntimeError(
"Не найдено ни одного подходящего поля времени (defaultZoneDateTime/scheduledTime/startTime/localDate+localTime)."
)
def get_game_id(team_games: list[dict], team: str) -> tuple[dict | None, dict | None]:
"""
Принимаем все расписание и ищем для домашней команды game_id.
Если сегодня нет матча, то берем game_id прошлой игры.
"""
now = datetime.now(APP_TZ)
today = now.date()
today_game = None
last_played = None
for g in team_games:
start = parse_game_start_dt(g)
status = g.get("game", {}).get("gameStatus", "").lower()
if (
start.date() == today
and today_game is None
and g["team1"]["name"].lower() == team.lower()
):
today_game = g
last_played = None
elif (
start <= now
and status == "resultconfirmed"
and g["team1"]["name"].lower() == team.lower()
):
today_game = None
last_played = g
return today_game, last_played
def is_game_live(game_obj: dict) -> bool:
"""
Пытаемся понять, идёт ли сейчас матч.
game_obj ожидается как today_game["game"] (из calendar).
"""
status = (game_obj.get("gameStatus") or "").lower()
# эвристика:
# - "resultconfirmed" -> матч кончился
# - "scheduled" / "notstarted" -> ещё не начался
# всё остальное считаем лайвом
if status in ("resultconfirmed", "finished", "result"):
return False
if status in ("scheduled", "notstarted", "draft"):
return False
return True
def get_interval_by_name(name: str) -> int:
"""
вернуть interval из URLS по имени ручки
"""
for u in URLS:
if u["name"] == name:
return u["interval"]
raise ValueError(f"interval not found for {name}")
def run_live_loop(
league: str,
season: str,
game_id: int,
lang: str,
game_meta: dict,
stop_event: threading.Event,
):
"""
Запускает два рабочих цикла:
- poll_game_live (опрашивает API матча)
- render_loop (собирает ui_state.json)
Управляет их остановкой.
"""
logger.info(
f"[LIVE_THREAD] start live loop for game_id={game_id} (league={league}, season={season})"
)
# отдельная сессия только для лайва
session = create_session()
# поток рендера
render_thread = threading.Thread(
target=render_loop,
args=(stop_event,), # дефолт out_name="game" в твоём render_loop можно оставить
daemon=False,
)
render_thread.start()
logger.info("[LIVE_THREAD] render thread spawned")
try:
poll_game_live(
session=session,
league=league,
season=season,
game_id=game_id,
lang=lang,
game_meta=game_meta,
stop_event=stop_event,
)
except Exception as e:
logger.exception(f"[LIVE_THREAD] crash in live loop for game_id={game_id}: {e}")
finally:
# какое бы ни было завершение, просим рендер остановиться
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}")
def Scores_Quarter(merged: dict, *, out_dir: str = "static") -> None:
"""
Поток, обновляющий JSON со счётом по четвертям.
"""
logger.info("START making json for scores quarter")
quarters = ["Q1", "Q2", "Q3", "Q4", "OT1", "OT2", "OT3", "OT4"]
score_by_quarter = [{"Q": q, "score1": "", "score2": ""} for q in quarters]
try:
# Сначала пробуем fullScore
full_score_str = merged.get("result", {}).get("game", {}).get("fullScore", "")
if full_score_str:
full_score_list = full_score_str.split(",")
for i, score_str in enumerate(full_score_list[: len(score_by_quarter)]):
parts = score_str.split(":")
if len(parts) == 2:
score_by_quarter[i]["score1"] = parts[0]
score_by_quarter[i]["score2"] = parts[1]
logger.info("Счёт по четвертям получен из fullScore.")
# Если нет fullScore, пробуем scoreByPeriods
elif "scoreByPeriods" in merged.get("result", {}):
periods = merged["result"]["scoreByPeriods"]
for i, score in enumerate(periods[: len(score_by_quarter)]):
score_by_quarter[i]["score1"] = str(score.get("score1", ""))
score_by_quarter[i]["score2"] = str(score.get("score2", ""))
logger.info("Счёт по четвертям получен из scoreByPeriods.")
else:
logger.debug("Нет данных по счёту, сохраняем пустые значения.")
out_path = "scores"
atomic_write_json(score_by_quarter, out_path)
logging.info("Сохранил payload: {out_path}")
except Exception as e:
logger.error(f"Ошибка в Scores_Quarter: {e}", exc_info=True)
def poll_game_live(
session,
league: str,
season: str,
game_id: int,
lang: str,
game_meta: dict,
stop_event: threading.Event,
):
slow_endpoints = ["game"] # "pregame-fullstats" можно добавить обратно
fast_endpoints = ["live-status", "box-score", "play-by-play"]
last_call = {name: 0 for name in slow_endpoints + fast_endpoints}
with ThreadPoolExecutor(max_workers=5) as executor:
while True:
# внешний стоп с клавиатуры / по команде
if stop_event.is_set():
logger.info(f"[POLL] stop_event set -> break live poll for game {game_id}")
break
now = time.time()
to_run = []
for ep in fast_endpoints + slow_endpoints:
interval = get_interval_by_name(ep)
if now - last_call[ep] >= interval:
to_run.append(ep)
futures = []
if to_run:
for ep in to_run:
futures.append(
executor.submit(
poll_one_endpoint,
session,
ep,
league,
season,
game_id,
lang,
)
)
game_finished = False
for fut in as_completed(futures):
try:
ep_name, data = fut.result()
last_call[ep_name] = now
if ep_name == "live-status":
if isinstance(data, dict):
st = (
data.get("status")
or data.get("gameStatus")
or ""
).lower()
if st in ("resultconfirmed", "finished", "result"):
logger.info(
f"Game {game_id} finished by live-status -> stop loop"
)
game_finished = True
except Exception as e:
logger.exception(f"poll endpoint error: {e}")
if not is_game_live(game_meta):
logger.info(
f"Game {game_id} no longer live by calendar meta -> stop loop"
)
break
if game_finished:
break
time.sleep(0.2)
# ещё одна точка выхода даже если статус не изменился
if stop_event.is_set():
logger.info(f"[POLL] stop_event set after sleep -> break live poll for game {game_id}")
break
def get_data_API(session, league: str, team: str, lang: str, stop_event: threading.Event):
json_seasons = fetch_api_data(
session, "seasons", host=HOST, league=league, lang=lang
)
if not json_seasons:
logger.error("Не удалось получить список сезонов")
return
season = json_seasons[0]["season"]
fetch_api_data(
session, "standings", host=HOST, league=league, season=season, lang=lang
)
json_calendar = fetch_api_data(
session, "calendar", host=HOST, league=league, season=season, lang=lang
)
if not json_calendar:
logger.error("Не удалось получить список матчей")
return
today_game, last_played = get_game_id(json_calendar, team)
if last_played and not today_game:
game_id = last_played["game"]["id"]
logger.info(f"Последний завершённый матч id={game_id}")
fetch_api_data(session, "game", host=HOST, game_id=game_id, lang=lang)
return
if today_game:
game_id = today_game["game"]["id"]
logger.info(f"Онлайн матч id={game_id}")
fetch_api_data(session, "game", host=HOST, game_id=game_id, lang=lang)
if is_game_live(today_game["game"]):
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...")
try:
t.join()
except KeyboardInterrupt:
logger.info("KeyboardInterrupt while waiting live thread -> stop_event")
stop_event.set()
t.join()
logger.info("live thread finished")
return
logger.info("Для этой команды игр сегодня нет и нет завершённой последней игры.")
def read_local_json(name: str, in_dir: str = "static"):
"""
Безопасно читает static/<name>.json.
Если файла нет или он в процессе записи -> вернёт None, но не упадёт.
"""
filename = os.path.join(in_dir, f"{name}.json")
try:
with open(filename, "r", encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return None
except json.JSONDecodeError:
# файл мог быть в моменте перезаписи -> просто пропускаем этот тик
return None
except Exception as ex:
logger.exception(f"read_local_json({name}) error: {ex}")
return None
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def build_render_state() -> dict:
"""
Читает сырые api_*.json и собирает удобный json для другой программы (графики и т.п.).
Возвращает dict, который потом пишем в static/ui_state.json
"""
game_data = read_local_json("api_game")
live_status_data = read_local_json("api_live-status")
box_score_data = read_local_json("api_box-score")
play_by_play_data = read_local_json("api_play-by-play")
game_data = game_data["result"]
# базовый безопасный каркас
for index_team, team in enumerate(game_data["teams"][1:]):
box_team = box_score_data["result"]["teams"][index_team]
for player in team.get("starts", []):
stat = next(
(
s
for s in box_team.get("starts", [])
if s.get("startNum") == player.get("startNum")
),
None,
)
if stat:
player["stats"] = stat
team["total"] = box_team.get("total", {})
game_data["plays"] = play_by_play_data.get("result", [])
game_data["scoreByPeriods"] = box_score_data["result"].get("scoreByPeriods", [])
game_data["fullScore"] = box_score_data["result"].get("fullScore", {})
game_data["live_status"] = live_status_data["result"]
merged: Dict[str, Any] = {
"meta": {
"generatedAt": _now_iso(),
"sourceHints": {
"boxScoreHas": "",
"pbpLen": "",
},
},
"result": game_data,
}
return merged
def format_time(seconds: float | int) -> str:
"""
Форматирует время в секундах в строку "M:SS".
Args:
seconds (float | int): Количество секунд.
Returns:
str: Время в формате "M:SS".
"""
try:
total_seconds = int(float(seconds))
minutes = total_seconds // 60
sec = total_seconds % 60
return f"{minutes}:{sec:02}"
except (ValueError, TypeError):
return "0:00"
def Json_Team_Generation(
merged: dict, *, out_dir: str = "static", who: str | None = None
) -> None:
"""
Единая точка: принимает уже нормализованный merged, делает нужные вычисления (если надо)
и сохраняет в JSON.
"""
# Здесь можно делать любые расчёты/агрегации...
# Пример предохранителя: сортировка плей-бай-плея по sequence
# plays = merged.get("result", {}).get("plays", [])
# if plays and isinstance(plays, list):
# try:
# plays.sort(key=lambda e: (e.get("sequence") is None, e.get("sequence"), e.get("time") or e.get("clock")))
# except Exception:
# pass
# Имя файла
# print(merged)
# merged =
if who == "team1":
for i in merged["result"]["teams"]:
if i["teamNumber"] == 1:
payload = i
elif who == "team2":
for i in merged["result"]["teams"]:
if i["teamNumber"] == 2:
payload = i
# online = (
# True
# if json_live_status
# and "status" in json_live_status
# and json_live_status["status"] == "Ok"
# and json_live_status["result"]["gameStatus"] == "Online"
# else False
# )
online = False
role_list = [
("Center", "C"),
("Guard", "G"),
("Forward", "F"),
("Power Forward", "PF"),
("Small Forward", "SF"),
("Shooting Guard", "SG"),
("Point Guard", "PG"),
("Forward-Center", "FC"),
]
starts = payload["starts"]
team = []
for item in starts:
player = {
"id": (item["personId"] if item["personId"] else ""),
"num": item["displayNumber"],
"startRole": item["startRole"],
"role": item["positionName"],
"roleShort": (
[
r[1]
for r in role_list
if r[0].lower() == item["positionName"].lower()
][0]
if any(r[0].lower() == item["positionName"].lower() for r in role_list)
else ""
),
"NameGFX": (
f"{item['firstName'].strip()} {item['lastName'].strip()}"
if item["firstName"] is not None and item["lastName"] is not None
else "Команда"
),
"captain": item["isCapitan"],
"age": item["age"] if item["age"] is not None else 0,
"height": f'{item["height"]} cm' if item["height"] else 0,
"weight": f'{item["weight"]} kg' if item["weight"] else 0,
"isStart": (item["stats"]["isStart"] if item["stats"] else False),
"isOn": (
"🏀" if item["stats"] and item["stats"]["isOnCourt"] is True else ""
),
"flag": f"https://flagicons.lipis.dev/flags/4x3/{'ru' if item['countryId'] is None and item['countryName'] == 'Russia' else '' if item['countryId'] is None else item['countryId'].lower() if item['countryName'] is not None else ''}.svg",
"pts": item["stats"]["points"] if item["stats"] else 0,
"pt-2": (
f"{item['stats']['goal2']}/{item['stats']['shot2']}"
if item["stats"]
else 0
),
"pt-3": (
f"{item['stats']['goal3']}/{item['stats']['shot3']}"
if item["stats"]
else 0
),
"pt-1": (
f"{item['stats']['goal1']}/{item['stats']['shot1']}"
if item["stats"]
else 0
),
"fg": (
f"{item['stats']['goal2'] + item['stats']['goal3']}/{item['stats']['shot2'] + item['stats']['shot3']}"
if item["stats"]
else 0
),
"ast": item["stats"]["assist"] if item["stats"] else 0,
"stl": item["stats"]["steal"] if item["stats"] else 0,
"blk": item["stats"]["block"] if item["stats"] else 0,
"blkVic": item["stats"]["blocked"] if item["stats"] else 0,
"dreb": item["stats"]["defReb"] if item["stats"] else 0,
"oreb": item["stats"]["offReb"] if item["stats"] else 0,
"reb": (
item["stats"]["defReb"] + item["stats"]["offReb"]
if item["stats"]
else 0
),
"to": item["stats"]["turnover"] if item["stats"] else 0,
"foul": item["stats"]["foul"] if item["stats"] else 0,
"foulT": item["stats"]["foulT"] if item["stats"] else 0,
"foulD": item["stats"]["foulD"] if item["stats"] else 0,
"foulC": item["stats"]["foulC"] if item["stats"] else 0,
"foulB": item["stats"]["foulB"] if item["stats"] else 0,
"fouled": item["stats"]["foulsOn"] if item["stats"] else 0,
"plusMinus": item["stats"]["plusMinus"] if item["stats"] else 0,
"dunk": item["stats"]["dunk"] if item["stats"] else 0,
"kpi": (
item["stats"]["points"]
+ item["stats"]["defReb"]
+ item["stats"]["offReb"]
+ item["stats"]["assist"]
+ item["stats"]["steal"]
+ item["stats"]["block"]
+ item["stats"]["foulsOn"]
+ (item["stats"]["goal1"] - item["stats"]["shot1"])
+ (item["stats"]["goal2"] - item["stats"]["shot2"])
+ (item["stats"]["goal3"] - item["stats"]["shot3"])
- item["stats"]["turnover"]
- item["stats"]["foul"]
if item["stats"]
else 0
),
"time": (format_time(item["stats"]["second"]) if item["stats"] else "0:00"),
"pts1q": 0,
"pts2q": 0,
"pts3q": 0,
"pts4q": 0,
"pts1h": 0,
"pts2h": 0,
"Name1GFX": (item["firstName"].strip() if item["firstName"] else ""),
"Name2GFX": (item["lastName"].strip() if item["lastName"] else ""),
"photoGFX": (
os.path.join(
"D:\\Photos",
merged["result"]["league"]["abcName"],
merged["result"][who]["name"],
# LEAGUE,
# data[who],
f"{item['displayNumber']}.png",
)
if item["startRole"] == "Player"
else ""
),
# "season": text,
"isOnCourt": (item["stats"]["isOnCourt"] if item["stats"] else False),
# "AvgPoints": (
# row_player_season_avg["points"]
# if row_player_season_avg
# and row_player_season_avg["points"] != ""
# else "0.0"
# ),
# "AvgAssist": (
# row_player_season_avg["assist"]
# if row_player_season_avg
# and row_player_season_avg["assist"] != ""
# else "0.0"
# ),
# "AvgBlocks": (
# row_player_season_avg["blockShot"]
# if row_player_season_avg
# and row_player_season_avg["blockShot"] != ""
# else "0.0"
# ),
# "AvgDefRebound": (
# row_player_season_avg["defRebound"]
# if row_player_season_avg
# and row_player_season_avg["defRebound"] != ""
# else "0.0"
# ),
# "AvgOffRebound": (
# row_player_season_avg["offRebound"]
# if row_player_season_avg
# and row_player_season_avg["offRebound"] != ""
# else "0.0"
# ),
# "AvgRebound": (
# row_player_season_avg["rebound"]
# if row_player_season_avg
# and row_player_season_avg["rebound"] != ""
# else "0.0"
# ),
# "AvgSteal": (
# row_player_season_avg["steal"]
# if row_player_season_avg
# and row_player_season_avg["steal"] != ""
# else "0.0"
# ),
# "AvgTurnover": (
# row_player_season_avg["turnover"]
# if row_player_season_avg
# and row_player_season_avg["turnover"] != ""
# else "0.0"
# ),
# "AvgFoul": (
# row_player_season_avg["foul"]
# if row_player_season_avg
# and row_player_season_avg["foul"] != ""
# else "0.0"
# ),
# "AvgOpponentFoul": (
# row_player_season_avg["foulsOnPlayer"]
# if row_player_season_avg
# and row_player_season_avg["foulsOnPlayer"] != ""
# else "0.0"
# ),
# "AvgPlusMinus": (
# row_player_season_avg["plusMinus"]
# if row_player_season_avg
# and row_player_season_avg["plusMinus"] != ""
# else "0.0"
# ),
# "AvgDunk": (
# row_player_season_avg["dunk"]
# if row_player_season_avg
# and row_player_season_avg["dunk"] != ""
# else "0.0"
# ),
# "AvgKPI": "0.0",
# "AvgPlayedTime": (
# row_player_season_avg["playedTime"]
# if row_player_season_avg
# and row_player_season_avg["playedTime"] != ""
# else "0:00"
# ),
# "Shot1Percent": calc_shot_percent_by_type(
# sum_stat, item["stats"], online, shot_types=1
# ),
# "Shot2Percent": calc_shot_percent_by_type(
# sum_stat, item["stats"], online, shot_types=2
# ),
# "Shot3Percent": calc_shot_percent_by_type(
# sum_stat, item["stats"], online, shot_types=3
# ),
# "Shot23Percent": calc_shot_percent_by_type(
# sum_stat, item["stats"], online, shot_types=[2, 3]
# ),
# "TPoints": sum_stat_with_online(
# "points", sum_stat, item["stats"], online
# ),
# "TShots1": calc_total_shots_str(
# sum_stat, item["stats"], online, 1
# ),
# "TShots2": calc_total_shots_str(
# sum_stat, item["stats"], online, 2
# ),
# "TShots3": calc_total_shots_str(
# sum_stat, item["stats"], online, 3
# ),
# "TShots23": calc_total_shots_str(
# sum_stat, item["stats"], online, [2, 3]
# ),
# "TShot1Percent": calc_shot_percent_by_type(
# sum_stat, item["stats"], online, shot_types=1
# ),
# "TShot2Percent": calc_shot_percent_by_type(
# sum_stat, item["stats"], online, shot_types=2
# ),
# "TShot3Percent": calc_shot_percent_by_type(
# sum_stat, item["stats"], online, shot_types=3
# ),
# "TShot23Percent": calc_shot_percent_by_type(
# sum_stat, item["stats"], online, shot_types=[2, 3]
# ),
# "TAssist": sum_stat_with_online(
# "assist", sum_stat, item["stats"], online
# ),
# "TBlocks": sum_stat_with_online(
# "blockShot", sum_stat, item["stats"], online
# ),
# "TDefRebound": sum_stat_with_online(
# "defRebound", sum_stat, item["stats"], online
# ),
# "TOffRebound": sum_stat_with_online(
# "offRebound", sum_stat, item["stats"], online
# ),
# "TRebound": (
# sum_stat_with_online(
# "defRebound", sum_stat, item["stats"], online
# )
# + sum_stat_with_online(
# "offRebound", sum_stat, item["stats"], online
# )
# ),
# "TSteal": sum_stat_with_online(
# "steal", sum_stat, item["stats"], online
# ),
# "TTurnover": sum_stat_with_online(
# "turnover", sum_stat, item["stats"], online
# ),
# "TFoul": sum_stat_with_online(
# "foul", sum_stat, item["stats"], online
# ),
# "TOpponentFoul": sum_stat_with_online(
# "foulsOnPlayer", sum_stat, item["stats"], online
# ),
# "TPlusMinus": 0,
# "TDunk": sum_stat_with_online(
# "dunk", sum_stat, item["stats"], online
# ),
# "TKPI": 0,
# "TPlayedTime": sum_stat["playedTime"] if sum_stat else "0:00",
# "TGameCount": (
# safe_int(sum_stat["games"])
# if sum_stat and sum_stat.get("games") != ""
# else 0
# )
# + (1 if online else 0),
# "TStartCount": (
# safe_int(sum_stat["isStarts"])
# if sum_stat and sum_stat.get("isStarts", 0) != ""
# else 0
# ),
# "CareerTShots1": calc_total_shots_str(
# row_player_career_sum, item["stats"], online, 1
# ),
# "CareerTShots2": calc_total_shots_str(
# row_player_career_sum, item["stats"], online, 2
# ),
# "CareerTShots3": calc_total_shots_str(
# row_player_career_sum, item["stats"], online, 3
# ),
# "CareerTShots23": calc_total_shots_str(
# row_player_career_sum, item["stats"], online, [2, 3]
# ),
# "CareerTShot1Percent": calc_shot_percent_by_type(
# row_player_career_sum, item["stats"], online, 1
# ),
# "CareerTShot2Percent": calc_shot_percent_by_type(
# row_player_career_sum, item["stats"], online, 2
# ),
# "CareerTShot3Percent": calc_shot_percent_by_type(
# row_player_career_sum, item["stats"], online, 3
# ),
# "CareerTShot23Percent": calc_shot_percent_by_type(
# row_player_career_sum, item["stats"], online, [2, 3]
# ),
# "CareerTPoints": sum_stat_with_online(
# "points", row_player_career_sum, item["stats"], online
# ),
# "CareerTAssist": sum_stat_with_online(
# "assist", row_player_career_sum, item["stats"], online
# ),
# "CareerTBlocks": sum_stat_with_online(
# "blockShot", row_player_career_sum, item["stats"], online
# ),
# "CareerTDefRebound": sum_stat_with_online(
# "defRebound", row_player_career_sum, item["stats"], online
# ),
# "CareerTOffRebound": sum_stat_with_online(
# "offRebound", row_player_career_sum, item["stats"], online
# ),
# "CareerTRebound": (
# sum_stat_with_online(
# "defRebound",
# row_player_career_sum,
# item["stats"],
# online,
# )
# + sum_stat_with_online(
# "offRebound",
# row_player_career_sum,
# item["stats"],
# online,
# )
# ),
# "CareerTSteal": sum_stat_with_online(
# "steal", row_player_career_sum, item["stats"], online
# ),
# "CareerTTurnover": sum_stat_with_online(
# "turnover", row_player_career_sum, item["stats"], online
# ),
# "CareerTFoul": sum_stat_with_online(
# "foul", row_player_career_sum, item["stats"], online
# ),
# "CareerTOpponentFoul": sum_stat_with_online(
# "foulsOnPlayer",
# row_player_career_sum,
# item["stats"],
# online,
# ),
# "CareerTPlusMinus": 0, # оставить как есть
# "CareerTDunk": sum_stat_with_online(
# "dunk", row_player_career_sum, item["stats"], online
# ),
# "CareerTPlayedTime": (
# row_player_career_sum["playedTime"]
# if row_player_career_sum
# else "0:00"
# ),
# "CareerTGameCount": sum_stat_with_online(
# "games", row_player_career_sum, item["stats"], online
# )
# + (1 if online else 0),
# "CareerTStartCount": sum_stat_with_online(
# "isStarts", row_player_career_sum, item["stats"], online
# ), # если нужно, можно +1 при старте
# "AvgCarPoints": (
# row_player_career_avg["points"]
# if row_player_career_avg
# and row_player_career_avg["points"] != ""
# else "0.0"
# ),
# "AvgCarAssist": (
# row_player_career_avg["assist"]
# if row_player_career_avg
# and row_player_career_avg["assist"] != ""
# else "0.0"
# ),
# "AvgCarBlocks": (
# row_player_career_avg["blockShot"]
# if row_player_career_avg
# and row_player_career_avg["blockShot"] != ""
# else "0.0"
# ),
# "AvgCarDefRebound": (
# row_player_career_avg["defRebound"]
# if row_player_career_avg
# and row_player_career_avg["defRebound"] != ""
# else "0.0"
# ),
# "AvgCarOffRebound": (
# row_player_career_avg["offRebound"]
# if row_player_career_avg
# and row_player_career_avg["offRebound"] != ""
# else "0.0"
# ),
# "AvgCarRebound": (
# row_player_career_avg["rebound"]
# if row_player_career_avg
# and row_player_career_avg["rebound"] != ""
# else "0.0"
# ),
# "AvgCarSteal": (
# row_player_career_avg["steal"]
# if row_player_career_avg
# and row_player_career_avg["steal"] != ""
# else "0.0"
# ),
# "AvgCarTurnover": (
# row_player_career_avg["turnover"]
# if row_player_career_avg
# and row_player_career_avg["turnover"] != ""
# else "0.0"
# ),
# "AvgCarFoul": (
# row_player_career_avg["foul"]
# if row_player_career_avg
# and row_player_career_avg["foul"] != ""
# else "0.0"
# ),
# "AvgCarOpponentFoul": (
# row_player_career_avg["foulsOnPlayer"]
# if row_player_career_avg
# and row_player_career_avg["foulsOnPlayer"] != ""
# else "0.0"
# ),
# "AvgCarPlusMinus": (
# row_player_career_avg["plusMinus"]
# if row_player_career_avg
# and row_player_career_avg["plusMinus"] != ""
# else "0.0"
# ),
# "AvgCarDunk": (
# row_player_career_avg["dunk"]
# if row_player_career_avg
# and row_player_career_avg["dunk"] != ""
# else "0.0"
# ),
# "AvgCarKPI": "0.0",
# "AvgCarPlayedTime": (
# row_player_career_avg["playedTime"]
# if row_player_career_avg
# and row_player_career_avg["playedTime"] != ""
# else "0:00"
# ),
# "HeadCoachStatsCareer": HeadCoachStatsCareer,
# "HeadCoachStatsTeam": HeadCoachStatsTeam,
# # "PTS_Career_High": get_carrer_high(item["personId"], "points"),
# # "AST_Career_High": get_carrer_high(item["personId"], "assist"),
# # "REB_Career_High": get_carrer_high(item["personId"], "rebound"),
# # "STL_Career_High": get_carrer_high(item["personId"], "steal"),
# # "BLK_Career_High": get_carrer_high(item["personId"], "blockShot"),
}
team.append(player)
count_player = sum(1 for x in team if x["startRole"] == "Player")
# print(count_player)
if count_player < 12:
if team: # Check if team is not empty
empty_rows = [
{
key: (
False
if key in ["captain", "isStart", "isOnCourt"]
else (
0
if key
in [
"id",
"pts",
"weight",
"height",
"age",
"ast",
"stl",
"blk",
"blkVic",
"dreb",
"oreb",
"reb",
"to",
"foul",
"foulT",
"foulD",
"foulC",
"foulB",
"fouled",
"plusMinus",
"dunk",
"kpi",
]
else ""
)
)
for key in team[0].keys()
}
for _ in range((4 if count_player <= 4 else 12) - count_player)
]
team.extend(empty_rows)
role_priority = {
"Player": 0,
"": 1,
"Coach": 2,
"Team": 3,
None: 4,
"Other": 5, # на случай неизвестных
}
# print(team)
sorted_team = sorted(
team,
key=lambda x: role_priority.get(
x.get("startRole", 99), 99
), # 99 — по умолчанию
)
out_path = f"{who}"
atomic_write_json(sorted_team, out_path)
logging.info("Сохранил payload: {out_path}")
top_sorted_team = sorted(
filter(lambda x: x["startRole"] in ["Player", ""], sorted_team),
key=lambda x: (
x["pts"],
x["dreb"] + x["oreb"],
x["ast"],
x["stl"],
x["blk"],
x["time"],
),
reverse=True,
)
for item in top_sorted_team:
item["pts"] = "" if item["num"] == "" else item["pts"]
item["foul"] = "" if item["num"] == "" else item["foul"]
out_path = f"top{who.replace('t','T')}"
atomic_write_json(top_sorted_team, out_path)
logging.info("Сохранил payload: {out_path}")
started_team = sorted(
filter(
lambda x: x["startRole"] == "Player" and x["isOnCourt"] is True,
sorted_team,
),
key=lambda x: int(x["num"]),
reverse=False,
)
out_path = f"started_{who}"
atomic_write_json(started_team, out_path)
logging.info("Сохранил payload: {out_path}")
def time_outs_func(data_pbp: list[dict]) -> tuple[str, int, str, int]:
"""
Вычисляет количество оставшихся таймаутов для обеих команд
и формирует строку состояния.
Args:
data_pbp: Список игровых событий (play-by-play).
Returns:
Кортеж: (строка команды 1, остаток, строка команды 2, остаток)
"""
timeout1 = []
timeout2 = []
for event in data_pbp:
if event.get("play") == 23:
if event.get("startNum") == 1:
timeout1.append(event)
elif event.get("startNum") == 2:
timeout2.append(event)
def timeout_status(timeout_list: list[dict], last_event: dict) -> tuple[str, int]:
period = last_event.get("period", 0)
sec = last_event.get("sec", 0)
if period < 3:
timeout_max = 2
count = sum(1 for t in timeout_list if t.get("period", 0) <= period)
quarter = "1st half"
elif period < 5:
count = sum(1 for t in timeout_list if 3 <= t.get("period", 0) <= period)
quarter = "2nd half"
if period == 4 and sec >= 4800 and count in (0, 1):
timeout_max = 2
else:
timeout_max = 3
else:
timeout_max = 1
count = sum(1 for t in timeout_list if t.get("period", 0) == period)
quarter = f"OverTime {period - 4}"
left = max(0, timeout_max - count)
word = "Time-outs" if left != 1 else "Time-out"
text = f"{left if left != 0 else 'No'} {word} left in {quarter}"
return text, left
if not data_pbp:
return "", 0, "", 0
last_event = data_pbp[-1]
t1_str, t1_left = timeout_status(timeout1, last_event)
t2_str, t2_left = timeout_status(timeout2, last_event)
return t1_str, t1_left, t2_str, t2_left
def add_data_for_teams(new_data: list[dict]) -> tuple[float, list, float]:
"""
Возвращает усреднённые статистики команды:
- средний возраст
- очки со старта и скамейки + их доли
- средний рост
Args:
new_data (list[dict]): Список игроков с полями "startRole", "stats", "age", "height"
Returns:
tuple: (avg_age: float, points: list, avg_height: float)
"""
players = [item for item in new_data if item.get("startRole") == "Player"]
points_start = 0
points_bench = 0
total_age = 0
total_height = 0
player_count = len(players)
for player in players:
stats = player.get("stats")
if stats:
is_start = stats.get("isStart")
# Очки
if is_start is True:
points_start += stats.get("points", 0)
elif is_start is False:
points_bench += stats.get("points", 0)
# Возраст и рост
total_age += player.get("age", 0) or 0
total_height += player.get("height", 0) or 0
total_points = points_start + points_bench
points_start_pro = (
f"{round(points_start * 100 / total_points)}%" if total_points else "0%"
)
points_bench_pro = (
f"{round(points_bench * 100 / total_points)}%" if total_points else "0%"
)
avg_age = round(total_age / player_count, 1) if player_count else 0
avg_height = round(total_height / player_count, 1) if player_count else 0
points = [points_start, points_start_pro, points_bench, points_bench_pro]
return avg_age, points, avg_height
def add_new_team_stat(
data: dict,
avg_age: float,
points: float,
avg_height: float,
timeout_str: str,
timeout_left: str,
) -> dict:
"""
Добавляет в словарь команды форматированную статистику.
Все значения приводятся к строкам.
Args:
data: Исходная статистика команды.
avg_age: Средний возраст команды (строка).
points: Кортеж из 4 строк: ptsStart, ptsStart_pro, ptsBench, ptsBench_pro.
avg_height: Средний рост (в см).
timeout_str: Строка отображения таймаутов.
timeout_left: Остаток таймаутов.
Returns:
Обновлённый словарь `data` с новыми ключами.
"""
def safe_int(v): # Локальная защита от ValueError/TypeError
try:
return int(v)
except (ValueError, TypeError):
return 0
def format_percent(goal, shot):
goal, shot = safe_int(goal), safe_int(shot)
return f"{round(goal * 100 / shot)}%" if shot else "0%"
goal1, shot1 = safe_int(data.get("goal1")), safe_int(data.get("shot1"))
goal2, shot2 = safe_int(data.get("goal2")), safe_int(data.get("shot2"))
goal3, shot3 = safe_int(data.get("goal3")), safe_int(data.get("shot3"))
def_reb = safe_int(data.get("defReb"))
off_reb = safe_int(data.get("offReb"))
data.update(
{
"pt-1": f"{goal1}/{shot1}",
"pt-2": f"{goal2}/{shot2}",
"pt-3": f"{goal3}/{shot3}",
"fg": f"{goal2 + goal3}/{shot2 + shot3}",
"pt-1_pro": format_percent(goal1, shot1),
"pt-2_pro": format_percent(goal2, shot2),
"pt-3_pro": format_percent(goal3, shot3),
"fg_pro": format_percent(goal2 + goal3, shot2 + shot3),
"Reb": str(def_reb + off_reb),
"avgAge": str(avg_age),
"ptsStart": str(points[0]),
"ptsStart_pro": str(points[1]),
"ptsBench": str(points[2]),
"ptsBench_pro": str(points[3]),
"avgHeight": f"{avg_height} cm",
"timeout_left": str(timeout_left),
"timeout_str": str(timeout_str),
}
)
# Приводим все значения к строкам, если нужно строго для сериализации
for k in data:
data[k] = str(data[k])
return data
stat_name_list = [
("points", "Очки", "points"),
("pt-1", "Штрафные", "free throws"),
("pt-1_pro", "штрафные, процент", "free throws pro"),
("pt-2", "2-очковые", "2-points"),
("pt-2_pro", "2-очковые, процент", "2-points pro"),
("pt-3", "3-очковые", "3-points"),
("pt-3_pro", "3-очковые, процент", "3-points pro"),
("fg", "очки с игры", "field goals"),
("fg_pro", "Очки с игры, процент", "field goals pro"),
("assist", "Передачи", "assists"),
("pass", "", ""),
("defReb", "подборы в защите", ""),
("offReb", "подборы в нападении", ""),
("Reb", "Подборы", "rebounds"),
("steal", "Перехваты", "steals"),
("block", "Блокшоты", "blocks"),
("blocked", "", ""),
("turnover", "Потери", "turnovers"),
("foul", "Фолы", "fouls"),
("foulsOn", "", ""),
("foulT", "", ""),
("foulD", "", ""),
("foulC", "", ""),
("foulB", "", ""),
("second", "секунды", "seconds"),
("dunk", "данки", "dunks"),
("fastBreak", "", "fast breaks"),
("plusMinus", "+/-", "+/-"),
("avgAge", "", "avg Age"),
("ptsBench", "", "Bench PTS"),
("ptsBench_pro", "", "Bench PTS, %"),
("ptsStart", "", "Start PTS"),
("ptsStart_pro", "", "Start PTS, %"),
("avgHeight", "", "avg height"),
("timeout_left", "", "timeout left"),
("timeout_str", "", "timeout str"),
]
def Team_Both_Stat(merged: dict, *, out_dir: str = "static") -> None:
"""
Обновляет файл team_stats.json, содержащий сравнение двух команд.
Аргументы:
stop_event (threading.Event): Событие для остановки цикла.
"""
logger.info("START making json for team statistics")
try:
teams = merged["result"]["teams"]
plays = merged["result"].get("plays", [])
# Разделение команд
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)
if not team_1 or not team_2:
logger.warning("Не найдены обе команды в данных")
# time.sleep()
# Таймауты
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_1 = add_new_team_stat(
team_1["total"],
avg_age_1,
points_1,
avg_height_1,
timeout_str1,
timeout_left1,
)
total_2 = add_new_team_stat(
team_2["total"],
avg_age_2,
points_2,
avg_height_2,
timeout_str2,
timeout_left2,
)
# Финальный JSON
result_json = []
for key in total_1:
val1 = (
int(total_1[key]) if isinstance(total_1[key], float) else total_1[key]
)
val2 = (
int(total_2[key]) if isinstance(total_2[key], float) else total_2[key]
)
stat_rus, stat_eng = "", ""
for s in stat_name_list:
if s[0] == key:
stat_rus, stat_eng = s[1], s[2]
break
result_json.append(
{
"name": key,
"nameGFX_rus": stat_rus,
"nameGFX_eng": stat_eng,
"val1": val1,
"val2": val2,
}
)
out_path = "team_stats"
atomic_write_json(result_json, out_path)
logging.info("Сохранил payload: {out_path}")
logger.debug("Успешно записаны данные в team_stats.json")
except Exception as e:
logger.error(f"Ошибка при обработке командной статистики: {e}", exc_info=True)
def render_loop(stop_event: threading.Event, out_name: str = "game"):
"""
Крутится в отдельном потоке.
Читает api_*.json, собирает финальный state
и сохраняет в static/<out_name>.json.
Работает, пока stop_event не установлен.
"""
logger.info("[RENDER_THREAD] start render loop")
while not stop_event.is_set():
try:
state = build_render_state()
Team_Both_Stat(state)
Json_Team_Generation(state, who="team1")
Json_Team_Generation(state, who="team2")
Scores_Quarter(state)
atomic_write_json([state["result"]["live_status"]], "live_status")
atomic_write_json(state["result"], out_name)
except Exception as ex:
logger.exception(f"[RENDER_THREAD] error while building render state: {ex}")
time.sleep(0.2)
logger.info("[RENDER_THREAD] stop render loop")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--league", required=True)
parser.add_argument("--team", required=True)
parser.add_argument("--lang", default="en")
args = parser.parse_args()
while True:
session = create_session()
stop_event = threading.Event()
try:
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}")
# Спим до 00:05 следующего дня
now = datetime.now(APP_TZ)
tomorrow = (now + timedelta(days=1)).replace(
hour=0, minute=5, second=0, microsecond=0
)
sleep_seconds = (tomorrow - now).total_seconds()
if sleep_seconds < 0:
tomorrow = (now + timedelta(days=2)).replace(
hour=0, minute=5, second=0, microsecond=0
)
sleep_seconds = (tomorrow - now).total_seconds()
logger.info(
f"Работа завершена. Засыпаем до {tomorrow.strftime('%d.%m %H:%M')} "
f"(~{round(sleep_seconds/3600, 2)} ч)."
)
try:
time.sleep(sleep_seconds)
except KeyboardInterrupt:
logger.info("KeyboardInterrupt во время сна -> выходим.")
break
# и снова в бой — новый день
stop_event = threading.Event()
session = create_session()
if __name__ == "__main__":
main()
if __name__ == "__main__":
main()