Compare commits

...

2 Commits

View File

@@ -1,4 +1,3 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
@@ -29,6 +28,7 @@ import time
import json import json
import argparse import argparse
import logging import logging
import pandas as pd
import logging.config import logging.config
import threading import threading
import concurrent.futures import concurrent.futures
@@ -41,6 +41,7 @@ from zoneinfo import ZoneInfo
from typing import Any, Dict, List from typing import Any, Dict, List
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from threading import Event, Lock
import requests import requests
@@ -72,20 +73,20 @@ DEFAULT_LANG = "en"
# URL-шаблоны (замени на реальные) # URL-шаблоны (замени на реальные)
HOST = "ref.russiabasket.org" HOST = "ref.russiabasket.org"
URL_SEASON = "https://{host}/api/abc/comps/seasons?Tag={league}&Lang={lang}" # вернёт JSON со списком сезонов URL_SEASON = "https://{host}/api/abc/comps/seasons?Tag={league}&Lang={lang}" # вернёт JSON со списком сезонов
URL_SCHEDULE = ( URL_SCHEDULE = "https://{host}/api/abc/comps/calendar?Tag={league}&Season={season}&Lang={lang}&MaxResultCount=1000" # расписание лиги (или команды)
"https://{host}/api/abc/comps/calendar?Tag={league}&Season={season}&Lang={lang}&MaxResultCount=1000" # расписание лиги (или команды)
)
# Статус конкретной игры (используется для проверки "онлайн?" раз в минуту) # Статус конкретной игры (используется для проверки "онлайн?" раз в минуту)
URL_GAME = "https://{host}/api/abc/games/game?Id={game_id}&lang={lang}" URL_GAME = "https://{host}/api/abc/games/game?Id={game_id}&lang={lang}"
# Быстрые запросы, когда матч онлайн (каждую секунду) # Быстрые запросы, когда матч онлайн (каждую секунду)
URL_BOX_SCORE = "https://{host}/api/abc/games/box-score?Id={game_id}&lang={lang}" URL_BOX_SCORE = "https://{host}/api/abc/games/box-score?Id={game_id}&lang={lang}"
URL_PLAY_BY_PLAY = "https://{host}/api/abc/games/play-by-play?Id={game_id}&lang={lang}" URL_PLAY_BY_PLAY = "https://{host}/api/abc/games/play-by-play?Id={game_id}&lang={lang}"
URL_LIVE_STATUS = "https://{host}/api/abc/games/live-status?Id={game_id}&lang={lang}" URL_LIVE_STATUS = "https://{host}/api/abc/games/live-status?Id={game_id}&lang={lang}"
URL_STANDINGS = "https://{host}/api/abc/comps/actual-standings?tag={league}&season={season}&lang={lang}"
# Интервалы опроса # Интервалы опроса
STATUS_CHECK_INTERVAL_SEC = 60 # проверять "онлайн?" раз в минуту STATUS_CHECK_INTERVAL_SEC = 60 # проверять "онлайн?" раз в минуту
ONLINE_FETCH_INTERVAL_SEC = 1 # когда матч онлайн, дергать три запроса каждую секунду ONLINE_FETCH_INTERVAL_SEC = 1 # когда матч онлайн, дергать три запроса каждую секунду
POLL_INTERVAL_OFFLINE_SEC = 300 # резервный интервал сна при ошибках/до старта POLL_INTERVAL_OFFLINE_SEC = 300 # резервный интервал сна при ошибках/до старта
TIMEOUT_DATA_OFF = 600
TELEGRAM_BOT_TOKEN = "7639240596:AAH0YtdQoWZSC-_R_EW4wKAHHNLIA0F_ARY" TELEGRAM_BOT_TOKEN = "7639240596:AAH0YtdQoWZSC-_R_EW4wKAHHNLIA0F_ARY"
# TELEGRAM_CHAT_ID = 228977654 # TELEGRAM_CHAT_ID = 228977654
@@ -146,7 +147,9 @@ logger.handlers[2].formatter.use_emoji = True
# ========================== # ==========================
def fetch_json(url: str, params: dict | None = None, session: requests.Session | None = None) -> dict: def fetch_json(
url: str, params: dict | None = None, session: requests.Session | None = None
) -> dict:
""" """
GET JSON с таймаутом и внятными ошибками. GET JSON с таймаутом и внятными ошибками.
Использует переданный session для keep-alive. Использует переданный session для keep-alive.
@@ -237,7 +240,9 @@ def parse_game_start_dt(item: dict) -> datetime:
except Exception as e: except Exception as e:
raise RuntimeError(f"Ошибка парсинга localDate/localTime '{ld} {lt}': {e}") raise RuntimeError(f"Ошибка парсинга localDate/localTime '{ld} {lt}': {e}")
raise RuntimeError("Не найдено ни одного подходящего поля времени (defaultZoneDateTime/scheduledTime/startTime/localDate+localTime).") raise RuntimeError(
"Не найдено ни одного подходящего поля времени (defaultZoneDateTime/scheduledTime/startTime/localDate+localTime)."
)
def extract_game_status(data: dict) -> str: def extract_game_status(data: dict) -> str:
@@ -254,21 +259,32 @@ def extract_game_status(data: dict) -> str:
# ---- ДОП. ЗАПРОСЫ ПРИ ОНЛАЙНЕ # ---- ДОП. ЗАПРОСЫ ПРИ ОНЛАЙНЕ
# ========================== # ==========================
def fetch_box_score(league: str, game_id: str, lang: str, session: requests.Session | None = None) -> dict:
def fetch_box_score(
league: str, game_id: str, lang: str, session: requests.Session | None = None
) -> dict:
url = URL_BOX_SCORE.format(host=HOST, league=league, game_id=game_id, lang=lang) url = URL_BOX_SCORE.format(host=HOST, league=league, game_id=game_id, lang=lang)
return fetch_json(url, session=session) return fetch_json(url, session=session)
def fetch_play_by_play(league: str, game_id: str, lang: str, session: requests.Session | None = None) -> dict:
def fetch_play_by_play(
league: str, game_id: str, lang: str, session: requests.Session | None = None
) -> dict:
url = URL_PLAY_BY_PLAY.format(host=HOST, league=league, game_id=game_id, lang=lang) url = URL_PLAY_BY_PLAY.format(host=HOST, league=league, game_id=game_id, lang=lang)
return fetch_json(url, session=session) return fetch_json(url, session=session)
def fetch_live_status(league: str, game_id: str, lang: str, session: requests.Session | None = None) -> dict:
def fetch_live_status(
league: str, game_id: str, lang: str, session: requests.Session | None = None
) -> dict:
url = URL_LIVE_STATUS.format(host=HOST, league=league, game_id=game_id, lang=lang) url = URL_LIVE_STATUS.format(host=HOST, league=league, game_id=game_id, lang=lang)
return fetch_json(url, session=session) return fetch_json(url, session=session)
def _now_iso() -> str: def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def _get(d: dict | None, *path, default=None): def _get(d: dict | None, *path, default=None):
"""Безопасно достаём вложенные ключи: _get(d, "result", "fullScore", default={})""" """Безопасно достаём вложенные ключи: _get(d, "result", "fullScore", default={})"""
cur = d or {} cur = d or {}
@@ -278,6 +294,7 @@ def _get(d: dict | None, *path, default=None):
cur = cur[p] cur = cur[p]
return cur return cur
def _dedup_plays(plays: List[dict]) -> List[dict]: def _dedup_plays(plays: List[dict]) -> List[dict]:
""" """
Удаляем дубли по стабильному идентификатору события. Удаляем дубли по стабильному идентификатору события.
@@ -298,9 +315,16 @@ def _dedup_plays(plays: List[dict]) -> List[dict]:
seen.add(key) seen.add(key)
out.append(ev) out.append(ev)
# если есть поле sequence/time — отсортируем, чтобы обработчик получал стабильный порядок # если есть поле sequence/time — отсортируем, чтобы обработчик получал стабильный порядок
out.sort(key=lambda e: (e.get("sequence") is None, e.get("sequence"), e.get("time") or e.get("clock"))) out.sort(
key=lambda e: (
e.get("sequence") is None,
e.get("sequence"),
e.get("time") or e.get("clock"),
)
)
return out return out
def merge_online_payloads( def merge_online_payloads(
game: dict, game: dict,
box_score: dict | None, box_score: dict | None,
@@ -312,41 +336,38 @@ def merge_online_payloads(
Ничего не знает о внутренней логике обработки — только нормализует. Ничего не знает о внутренней логике обработки — только нормализует.
""" """
# исходные куски # исходные куски
plays_raw: List[dict] = _get(play_by_play, "result", default=[]) or [] # plays_raw: List[dict] = _get(play_by_play, "result", default=[]) or []
score_by_periods = _get(box_score, "result", "scoreByPeriods", default=[]) or [] # score_by_periods = _get(box_score, "result", "scoreByPeriods", default=[]) or []
full_score = _get(box_score, "result", "fullScore", default={}) or {} # full_score = _get(box_score, "result", "fullScore", default={}) or {}
teams = _get(box_score, "result", "teams", default={}) or {} # если пригодится в обработчике # teams = _get(box_score, "result", "teams", default={}) or {} # если пригодится в обработчике
players = _get(box_score, "result", "players", default=[]) or [] # players = _get(box_score, "result", "players", default=[]) or []
# live
period = _get(live_status, "result", "period") # box_score = _get(box_score, "result", "teams", default=[]) or []
clock = _get(live_status, "result", "clock") # fullScore = _get(box_score, "result", "fullScore", default="") or ""
status = _get(live_status, "result", "status") # e.g., "inprogress", "ended", "scheduled"
# # live
# live_status = _get(live_status, "result", "live_status")
# period = _get(live_status, "result", "period")
# clock = _get(live_status, "result", "clock")
# status = _get(live_status, "result", "status") # e.g., "inprogress", "ended", "scheduled"
# нормализация/дедуп # нормализация/дедуп
plays = _dedup_plays(plays_raw) # plays = _dedup_plays(plays_raw)
game["result"]["plays"] = play_by_play.get("result", [])
game["result"]["scoreByPeriods"] = box_score["result"].get("scoreByPeriods", [])
game["result"]["fullScore"] = box_score["result"].get("fullScore", {})
game["result"]["live_status"] = live_status["result"]
merged: Dict[str, Any] = { merged: Dict[str, Any] = {
"meta": { "meta": {
"generatedAt": _now_iso(), "generatedAt": _now_iso(),
"sourceHints": { "sourceHints": {
"boxScoreHas": list((_get(box_score, "result") or {}).keys()), "boxScoreHas": list((_get(box_score, "result") or {}).keys()),
"pbpLen": len(plays), "pbpLen": "",
}, },
}, },
"result": { "result": game,
# то, что просил: три ключа (плюс ещё полезные поля)
"plays": plays,
"scoreByPeriods": score_by_periods,
"fullScore": full_score,
# добавим live — обработчику пригодится
"period": period,
"clock": clock,
"status": status,
# опционально: передадим команды/игроков, если есть в box score
"teams": teams,
"players": players,
},
} }
return merged return merged
@@ -381,6 +402,7 @@ def is_already_merged(obj: dict) -> bool:
and isinstance(r.get("scoreByPeriods", []), list) and isinstance(r.get("scoreByPeriods", []), list)
) )
def ensure_merged_payload( def ensure_merged_payload(
game_or_merged: dict | None = None, game_or_merged: dict | None = None,
*, *,
@@ -423,20 +445,26 @@ def ensure_merged_payload(
}, },
"result": g, # положим сырой ответ целиком — чтобы файл гарантированно записался "result": g, # положим сырой ответ целиком — чтобы файл гарантированно записался
} }
raise ValueError("ensure_merged_payload: не передан ни уже-склеенный game, ни box/pbp/live.") raise ValueError(
"ensure_merged_payload: не передан ни уже-склеенный game, ни box/pbp/live."
)
def atomic_write_json(path: str | Path, data: dict, ensure_dirs: bool = True) -> None: def atomic_write_json(path: str | Path, data: dict, ensure_dirs: bool = True) -> None:
path = Path(path) path = Path(path)
if ensure_dirs: if ensure_dirs:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
# атомарная запись: пишем во временный файл и переименовываем # атомарная запись: пишем во временный файл и переименовываем
with tempfile.NamedTemporaryFile("w", delete=False, dir=str(path.parent), encoding="utf-8") as tmp: with tempfile.NamedTemporaryFile(
"w", delete=False, dir=str(path.parent), encoding="utf-8"
) as tmp:
json.dump(data, tmp, ensure_ascii=False, indent=2) json.dump(data, tmp, ensure_ascii=False, indent=2)
tmp.flush() tmp.flush()
os.fsync(tmp.fileno()) os.fsync(tmp.fileno())
tmp_name = tmp.name tmp_name = tmp.name
os.replace(tmp_name, path) os.replace(tmp_name, path)
def format_time(seconds: float | int) -> str: def format_time(seconds: float | int) -> str:
""" """
Форматирует время в секундах в строку "M:SS". Форматирует время в секундах в строку "M:SS".
@@ -456,7 +484,9 @@ def format_time(seconds: float | int) -> str:
return "0:00" return "0:00"
def Json_Team_Generation(merged: dict, *, out_dir: str = "static", who: str | None = None) -> None: def Json_Team_Generation(
merged: dict, *, out_dir: str = "static", who: str | None = None
) -> None:
""" """
Единая точка: принимает уже нормализованный merged, делает нужные вычисления (если надо) Единая точка: принимает уже нормализованный merged, делает нужные вычисления (если надо)
и сохраняет в JSON. и сохраняет в JSON.
@@ -498,16 +528,12 @@ def Json_Team_Generation(merged: dict, *, out_dir: str = "static", who: str | No
("Shooting Guard", "SG"), ("Shooting Guard", "SG"),
("Point Guard", "PG"), ("Point Guard", "PG"),
("Forward-Center", "FC"), ("Forward-Center", "FC"),
] ]
starts = payload["starts"] starts = payload["starts"]
team = [] team = []
for item in starts: for item in starts:
player = { player = {
"id": ( "id": (item["personId"] if item["personId"] else ""),
item["personId"]
if item["personId"]
else ""
),
"num": item["displayNumber"], "num": item["displayNumber"],
"startRole": item["startRole"], "startRole": item["startRole"],
"role": item["positionName"], "role": item["positionName"],
@@ -517,29 +543,21 @@ def Json_Team_Generation(merged: dict, *, out_dir: str = "static", who: str | No
for r in role_list for r in role_list
if r[0].lower() == item["positionName"].lower() if r[0].lower() == item["positionName"].lower()
][0] ][0]
if any( if any(r[0].lower() == item["positionName"].lower() for r in role_list)
r[0].lower() == item["positionName"].lower()
for r in role_list
)
else "" else ""
), ),
"NameGFX": ( "NameGFX": (
f"{item['firstName'].strip()} {item['lastName'].strip()}" f"{item['firstName'].strip()} {item['lastName'].strip()}"
if item["firstName"] is not None if item["firstName"] is not None and item["lastName"] is not None
and item["lastName"] is not None
else "Команда" else "Команда"
), ),
"captain": item["isCapitan"], "captain": item["isCapitan"],
"age": item["age"] if item["age"] is not None else 0, "age": item["age"] if item["age"] is not None else 0,
"height": f'{item["height"]} cm' if item["height"] else 0, "height": f'{item["height"]} cm' if item["height"] else 0,
"weight": f'{item["weight"]} kg' if item["weight"] else 0, "weight": f'{item["weight"]} kg' if item["weight"] else 0,
"isStart": ( "isStart": (item["stats"]["isStart"] if item["stats"] else False),
item["stats"]["isStart"] if item["stats"] else False
),
"isOn": ( "isOn": (
"🏀" "🏀" if item["stats"] and item["stats"]["isOnCourt"] is True else ""
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", "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, "pts": item["stats"]["points"] if item["stats"] else 0,
@@ -599,23 +617,15 @@ def Json_Team_Generation(merged: dict, *, out_dir: str = "static", who: str | No
if item["stats"] if item["stats"]
else 0 else 0
), ),
"time": ( "time": (format_time(item["stats"]["second"]) if item["stats"] else "0:00"),
format_time(item["stats"]["second"])
if item["stats"]
else "0:00"
),
"pts1q": 0, "pts1q": 0,
"pts2q": 0, "pts2q": 0,
"pts3q": 0, "pts3q": 0,
"pts4q": 0, "pts4q": 0,
"pts1h": 0, "pts1h": 0,
"pts2h": 0, "pts2h": 0,
"Name1GFX": ( "Name1GFX": (item["firstName"].strip() if item["firstName"] else ""),
item["firstName"].strip() if item["firstName"] else "" "Name2GFX": (item["lastName"].strip() if item["lastName"] else ""),
),
"Name2GFX": (
item["lastName"].strip() if item["lastName"] else ""
),
"photoGFX": ( "photoGFX": (
os.path.join( os.path.join(
"D:\\Photos", "D:\\Photos",
@@ -629,9 +639,7 @@ def Json_Team_Generation(merged: dict, *, out_dir: str = "static", who: str | No
else "" else ""
), ),
# "season": text, # "season": text,
"isOnCourt": ( "isOnCourt": (item["stats"]["isOnCourt"] if item["stats"] else False),
item["stats"]["isOnCourt"] if item["stats"] else False
),
# "AvgPoints": ( # "AvgPoints": (
# row_player_season_avg["points"] # row_player_season_avg["points"]
# if row_player_season_avg # if row_player_season_avg
@@ -1012,9 +1020,7 @@ def Json_Team_Generation(merged: dict, *, out_dir: str = "static", who: str | No
) )
for key in team[0].keys() for key in team[0].keys()
} }
for _ in range( for _ in range((4 if count_player <= 4 else 12) - count_player)
(4 if count_player <= 4 else 12) - count_player
)
] ]
team.extend(empty_rows) team.extend(empty_rows)
role_priority = { role_priority = {
@@ -1069,6 +1075,7 @@ def Json_Team_Generation(merged: dict, *, out_dir: str = "static", who: str | No
atomic_write_json(out_path, started_team) atomic_write_json(out_path, started_team)
logging.info("Сохранил payload: {out_path}") logging.info("Сохранил payload: {out_path}")
def time_outs_func(data_pbp: list[dict]) -> tuple[str, int, str, int]: def time_outs_func(data_pbp: list[dict]) -> tuple[str, int, str, int]:
""" """
Вычисляет количество оставшихся таймаутов для обеих команд Вычисляет количество оставшихся таймаутов для обеих команд
@@ -1124,6 +1131,7 @@ def time_outs_func(data_pbp: list[dict]) -> tuple[str, int, str, int]:
return t1_str, t1_left, t2_str, t2_left return t1_str, t1_left, t2_str, t2_left
def add_data_for_teams(new_data: list[dict]) -> tuple[float, list, float]: def add_data_for_teams(new_data: list[dict]) -> tuple[float, list, float]:
""" """
Возвращает усреднённые статистики команды: Возвращает усреднённые статистики команды:
@@ -1174,6 +1182,7 @@ def add_data_for_teams(new_data: list[dict]) -> tuple[float, list, float]:
points = [points_start, points_start_pro, points_bench, points_bench_pro] points = [points_start, points_start_pro, points_bench, points_bench_pro]
return avg_age, points, avg_height return avg_age, points, avg_height
def add_new_team_stat( def add_new_team_stat(
data: dict, data: dict,
avg_age: float, avg_age: float,
@@ -1243,6 +1252,7 @@ def add_new_team_stat(
return data return data
stat_name_list = [ stat_name_list = [
("points", "Очки", "points"), ("points", "Очки", "points"),
("pt-1", "Штрафные", "free throws"), ("pt-1", "Штрафные", "free throws"),
@@ -1305,22 +1315,14 @@ def Team_Both_Stat(merged: dict, *, out_dir: str = "static") -> None:
# time.sleep() # time.sleep()
# Таймауты # Таймауты
timeout_str1, timeout_left1, timeout_str2, timeout_left2 = time_outs_func( timeout_str1, timeout_left1, timeout_str2, timeout_left2 = time_outs_func(plays)
plays
)
# Возраст, очки, рост # Возраст, очки, рост
avg_age_1, points_1, avg_height_1 = add_data_for_teams( avg_age_1, points_1, avg_height_1 = add_data_for_teams(team_1.get("starts", []))
team_1.get("starts", []) avg_age_2, points_2, avg_height_2 = add_data_for_teams(team_2.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"): if not team_1.get("total") or not team_2.get("total"):
logger.debug( logger.debug("Нет total у команд — пропускаю перезапись team_stats.json")
"Нет total у команд — пропускаю перезапись team_stats.json"
)
# Форматирование общей статистики (как и было) # Форматирование общей статистики (как и было)
total_1 = add_new_team_stat( total_1 = add_new_team_stat(
@@ -1340,19 +1342,14 @@ def Team_Both_Stat(merged: dict, *, out_dir: str = "static") -> None:
timeout_left2, timeout_left2,
) )
# Финальный JSON # Финальный JSON
result_json = [] result_json = []
for key in total_1: for key in total_1:
val1 = ( val1 = (
int(total_1[key]) int(total_1[key]) if isinstance(total_1[key], float) else total_1[key]
if isinstance(total_1[key], float)
else total_1[key]
) )
val2 = ( val2 = (
int(total_2[key]) int(total_2[key]) if isinstance(total_2[key], float) else total_2[key]
if isinstance(total_2[key], float)
else total_2[key]
) )
stat_rus, stat_eng = "", "" stat_rus, stat_eng = "", ""
for s in stat_name_list: for s in stat_name_list:
@@ -1376,9 +1373,7 @@ def Team_Both_Stat(merged: dict, *, out_dir: str = "static") -> None:
logger.debug("Успешно записаны данные в team_stats.json") logger.debug("Успешно записаны данные в team_stats.json")
except Exception as e: except Exception as e:
logger.error( logger.error(f"Ошибка при обработке командной статистики: {e}", exc_info=True)
f"Ошибка при обработке командной статистики: {e}", exc_info=True
)
def Referee(merged: dict, *, out_dir: str = "static") -> None: def Referee(merged: dict, *, out_dir: str = "static") -> None:
@@ -1411,9 +1406,7 @@ def Referee(merged: dict, *, out_dir: str = "static") -> None:
referees = [] referees = []
for r in referees_raw: for r in referees_raw:
flag_code = ( flag_code = r.get("countryId", "").lower() if r.get("countryName") else ""
r.get("countryId", "").lower() if r.get("countryName") else ""
)
referees.append( referees.append(
{ {
"displayNumber": r.get("displayNumber", ""), "displayNumber": r.get("displayNumber", ""),
@@ -1454,9 +1447,7 @@ def Scores_Quarter(merged: dict, *, out_dir: str = "static") -> None:
score_by_quarter = [{"Q": q, "score1": "", "score2": ""} for q in quarters] score_by_quarter = [{"Q": q, "score1": "", "score2": ""} for q in quarters]
try: try:
# Сначала пробуем fullScore # Сначала пробуем fullScore
full_score_str = ( full_score_str = merged.get("result", {}).get("game", {}).get("fullScore", "")
merged.get("result", {}).get("game", {}).get("fullScore", "")
)
if full_score_str: if full_score_str:
full_score_list = full_score_str.split(",") full_score_list = full_score_str.split(",")
for i, score_str in enumerate(full_score_list[: len(score_by_quarter)]): for i, score_str in enumerate(full_score_list[: len(score_by_quarter)]):
@@ -1484,6 +1475,104 @@ def Scores_Quarter(merged: dict, *, out_dir: str = "static") -> None:
logger.error(f"Ошибка в Scores_Quarter: {e}", exc_info=True) logger.error(f"Ошибка в Scores_Quarter: {e}", exc_info=True)
def status_online_func(merged: dict, *, out_dir: str = "static") -> None:
"""
Получает онлайн-статус игры и возвращает данные + путь к PNG-фолам.
"""
try:
out_path = Path(out_dir) / "live_status.json"
if "live_status" in merged["result"]:
status_data = merged["result"]["live_status"]
atomic_write_json(out_path, status_data)
else:
logger.warning("Матч не ОНЛАЙН!!!!")
atomic_write_json(
out_path,
[
{
"foulsA": 0,
"foulsB": 0,
}
],
)
logging.info("Сохранил payload: {out_path}")
except Exception as e:
logger.error(f"Ошибка в status_online_func: {e}", exc_info=True)
return None
def Standing_func(league, season, lang, stop_event: threading.Event, out_dir: str = "static") -> None:
logger.info("START making json for standings")
while not stop_event.is_set():
try:
url = URL_STANDINGS.format(host=HOST, league=league, season=season, lang=lang)
data_standings = fetch_json(url)
if data_standings and "items" in data_standings and data_standings["items"]:
standings_temp = data_standings["items"]
for item in standings_temp:
if "standings" in item and item["standings"] != []:
standings_temp = item["standings"]
df = pd.json_normalize(standings_temp)
del df["scores"]
if not df["totalWin"].isna().all():
df["w_l"] = (
df["totalWin"].astype(str)
+ " / "
+ df["totalDefeat"].astype(str)
)
df["procent"] = df.apply(
lambda row: (
0
if row["w_l"] == "0 / 0"
or row["totalGames"] == 0
or pd.isna(row["totalWin"])
else round(
row["totalWin"] * 100 / row["totalGames"]
+ 0.000005
)
),
axis=1,
)
df["plus_minus"] = (
df["totalGoalPlus"] - df["totalGoalMinus"]
)
filepath = os.path.join(
out_dir,
f"standings_{league}_{item['comp']['name'].replace(' ', '_')}.json",
)
df.to_json(
filepath,
orient="records",
force_ascii=False,
indent=4,
)
logger.info("Standings data saved successfully.")
elif "playoffPairs" in item and item["playoffPairs"] != []:
standings_temp = item["playoffPairs"]
df = pd.json_normalize(standings_temp)
filepath = os.path.join(
out_dir,
f"standings_{league}_{item['comp']['name'].replace(' ', '_')}.json",
)
df.to_json(
filepath,
orient="records",
force_ascii=False,
indent=4,
)
logger.info("Standings data saved successfully.")
except Exception as e:
logger.warning(f"Ошибка в турнирном положении: {e}")
stop_event.wait(TIMEOUT_DATA_OFF)
# ========================== # ==========================
# ---- ДОМЕННАЯ ЛОГИКА # ---- ДОМЕННАЯ ЛОГИКА
# ========================== # ==========================
@@ -1492,7 +1581,9 @@ def Scores_Quarter(merged: dict, *, out_dir: str = "static") -> None:
def validate_league_or_die(league: str) -> str: def validate_league_or_die(league: str) -> str:
league = (league or DEFAULT_LEAGUE).lower().strip() league = (league or DEFAULT_LEAGUE).lower().strip()
if league not in ALLOWED_LEAGUES: if league not in ALLOWED_LEAGUES:
logger.warning(f"Неверный тег лиги: '{league}'. Допустимо: {sorted(ALLOWED_LEAGUES)}") logger.warning(
f"Неверный тег лиги: '{league}'. Допустимо: {sorted(ALLOWED_LEAGUES)}"
)
sys.exit(2) sys.exit(2)
return league return league
@@ -1502,16 +1593,16 @@ def get_last_season_or_die(league: str, lang: str) -> str:
try: try:
data = fetch_json(url) data = fetch_json(url)
season = extract_last_season(data) season = extract_last_season(data)
logging.info( logging.info(f"Последний сезон для {league}: {season}")
f"Последний сезон для {league}: {season}"
)
return season return season
except Exception as e: except Exception as e:
logger.warning(f"Не получилось получить последний сезон для {league}: {e}") logger.warning(f"Не получилось получить последний сезон для {league}: {e}")
sys.exit(3) sys.exit(3)
def get_team_schedule_or_die(league: str, season: str, team: str, lang: str) -> list[dict]: def get_team_schedule_or_die(
league: str, season: str, team: str, lang: str
) -> list[dict]:
url = URL_SCHEDULE.format(host=HOST, league=league, season=season, lang=lang) url = URL_SCHEDULE.format(host=HOST, league=league, season=season, lang=lang)
try: try:
data = fetch_json(url) data = fetch_json(url)
@@ -1545,7 +1636,7 @@ def pick_today_or_last_played(
return today_game, last_played return today_game, last_played
def is_game_online(league: str, game_id: str, lang:str) -> str: def is_game_online(league: str, game_id: str, lang: str) -> str:
""" """
Возвращает статус: inprogress|scheduled|finished (или то, что твой API даёт). Возвращает статус: inprogress|scheduled|finished (или то, что твой API даёт).
""" """
@@ -1584,6 +1675,7 @@ class PostProcessor:
Team_Both_Stat(merged, out_dir="static") Team_Both_Stat(merged, out_dir="static")
Referee(merged, out_dir="static") Referee(merged, out_dir="static")
Scores_Quarter(merged, out_dir="static") Scores_Quarter(merged, out_dir="static")
status_online_func(merged, out_dir="static")
except Exception as e: except Exception as e:
logging.exception(f"Postproc failed: {e}") logging.exception(f"Postproc failed: {e}")
@@ -1592,7 +1684,9 @@ class PostProcessor:
class OnlinePoller: class OnlinePoller:
def __init__(self, league: str, game_id: str, lang: str, on_update: callable | None = None): def __init__(
self, league: str, game_id: str, lang: str, on_update: callable | None = None
):
self.league = league self.league = league
self.game_id = game_id self.game_id = game_id
self.lang = lang self.lang = lang
@@ -1605,19 +1699,24 @@ class OnlinePoller:
# 1) Постоянная сессия и пул соединений # 1) Постоянная сессия и пул соединений
self._session = requests.Session() self._session = requests.Session()
retry = Retry( retry = Retry(
total=2, connect=2, read=2, backoff_factor=0.1, total=2,
connect=2,
read=2,
backoff_factor=0.1,
status_forcelist=(502, 503, 504), status_forcelist=(502, 503, 504),
allowed_methods=frozenset(["GET"]) allowed_methods=frozenset(["GET"]),
) )
adapter = HTTPAdapter(pool_connections=1, pool_maxsize=10, max_retries=retry) adapter = HTTPAdapter(pool_connections=1, pool_maxsize=10, max_retries=retry)
self._session.mount("http://", adapter) self._session.mount("http://", adapter)
self._session.mount("https://", adapter) self._session.mount("https://", adapter)
self._session.headers.update({ self._session.headers.update(
{
"Connection": "keep-alive", "Connection": "keep-alive",
"Accept": "application/json, */*", "Accept": "application/json, */*",
"Accept-Encoding": "gzip, deflate, br", "Accept-Encoding": "gzip, deflate, br",
"User-Agent": "game-watcher/1.0" "User-Agent": "game-watcher/1.0",
}) }
)
def stop(self): def stop(self):
if self._thread and self._thread.is_alive(): if self._thread and self._thread.is_alive():
@@ -1641,9 +1740,27 @@ class OnlinePoller:
started = time.perf_counter() started = time.perf_counter()
try: try:
futures = [ futures = [
pool.submit(fetch_box_score, self.league, self.game_id, self.lang, self._session), pool.submit(
pool.submit(fetch_play_by_play, self.league, self.game_id, self.lang, self._session), fetch_box_score,
pool.submit(fetch_live_status, self.league, self.game_id, self.lang, self._session), self.league,
self.game_id,
self.lang,
self._session,
),
pool.submit(
fetch_play_by_play,
self.league,
self.game_id,
self.lang,
self._session,
),
pool.submit(
fetch_live_status,
self.league,
self.game_id,
self.lang,
self._session,
),
] ]
bs, pbp, ls = (f.result() for f in futures) bs, pbp, ls = (f.result() for f in futures)
merged = ensure_merged_payload( merged = ensure_merged_payload(
@@ -1691,7 +1808,10 @@ class OnlinePoller:
self._thread.start() self._thread.start()
self._log.info(f"Онлайн-поллер для игры {self.game_id} запущен.") self._log.info(f"Онлайн-поллер для игры {self.game_id} запущен.")
def monitor_game_loop(league: str, game_id: str, lang:str, stop_event: threading.Event) -> None:
def monitor_game_loop(
league: str, game_id: str, lang: str, stop_event: threading.Event
) -> None:
logger.info(f"Старт мониторинга игры {game_id} ({league}).") logger.info(f"Старт мониторинга игры {game_id} ({league}).")
poller = OnlinePoller(league, game_id, lang) poller = OnlinePoller(league, game_id, lang)
was_online = False was_online = False
@@ -1707,10 +1827,14 @@ def monitor_game_loop(league: str, game_id: str, lang:str, stop_event: threading
break break
if is_online and not was_online: if is_online and not was_online:
logger.info(f"Матч {game_id} перешёл в онлайн.\nЗапускаем быстрый опрос (1 сек).") logger.info(
f"Матч {game_id} перешёл в онлайн.\nЗапускаем быстрый опрос (1 сек)."
)
poller.start() poller.start()
elif not is_online and was_online: elif not is_online and was_online:
logger.info(f"Матч {game_id} вышел из онлайна (или ещё не стартовал).\nОстанавливаем быстрый опрос.") logger.info(
f"Матч {game_id} вышел из онлайна (или ещё не стартовал).\nОстанавливаем быстрый опрос."
)
poller.stop() poller.stop()
was_online = is_online was_online = is_online
@@ -1730,7 +1854,9 @@ def monitor_game_loop(league: str, game_id: str, lang:str, stop_event: threading
def next_midnight_local(now: datetime) -> datetime: def next_midnight_local(now: datetime) -> datetime:
tomorrow = (now + timedelta(days=1)).date() tomorrow = (now + timedelta(days=1)).date()
return datetime.combine(tomorrow, datetime.min.time(), tzinfo=APP_TZ) + timedelta(minutes=5) return datetime.combine(tomorrow, datetime.min.time(), tzinfo=APP_TZ) + timedelta(
minutes=5
)
# return now + timedelta(seconds=30) # return now + timedelta(seconds=30)
@@ -1834,9 +1960,7 @@ def main():
parser.add_argument( parser.add_argument(
"--team", type=str, required=True, help="код/тег команды (например, BOS)" "--team", type=str, required=True, help="код/тег команды (например, BOS)"
) )
parser.add_argument( parser.add_argument("--lang", type=str, default="en", help="язык получения данных")
"--lang", type=str, default="en", help="язык получения данных"
)
parser.add_argument( parser.add_argument(
"--log-level", type=str, default="INFO", help="DEBUG|INFO|WARNING|ERROR" "--log-level", type=str, default="INFO", help="DEBUG|INFO|WARNING|ERROR"
) )
@@ -1844,7 +1968,9 @@ def main():
print(args) print(args)
# logger.info(f"Запуск программы пользователем: {MYHOST}") # logger.info(f"Запуск программы пользователем: {MYHOST}")
logger.info(f"Запуск с параметрами:\nleague={args.league}\nteam={args.team}\nlang={args.lang}") logger.info(
f"Запуск с параметрами:\nleague={args.league}\nteam={args.team}\nlang={args.lang}"
)
league = validate_league_or_die(args.league) league = validate_league_or_die(args.league)
team = args.team.lower() team = args.team.lower()
@@ -1852,6 +1978,10 @@ def main():
# 1) Узнать последний сезон # 1) Узнать последний сезон
season = get_last_season_or_die(league, args.lang) season = get_last_season_or_die(league, args.lang)
# 2) Получить расписание для команды # 2) Получить расписание для команды
team_games = get_team_schedule_or_die(league, season, team, args.lang) team_games = get_team_schedule_or_die(league, season, team, args.lang)
if not team_games: if not team_games:
@@ -1875,20 +2005,23 @@ def main():
if last_played: if last_played:
game_id = last_played["game"]["id"] game_id = last_played["game"]["id"]
try: try:
url = URL_GAME.format(host=HOST, league=league, game_id=game_id, lang=args.lang) url = URL_GAME.format(
host=HOST, league=league, game_id=game_id, lang=args.lang
)
game_json = fetch_json(url) game_json = fetch_json(url)
merged = ensure_merged_payload( merged = ensure_merged_payload(
game_json, game_json,
game_meta={ game_meta={
"id": game_json.get("result", {}).get("gameId"), "id": game_json.get("result", {}).get("gameId"),
"league": args.league "league": args.league,
} },
) )
Json_Team_Generation(merged, out_dir="static", who="team1") Json_Team_Generation(merged, out_dir="static", who="team1")
Json_Team_Generation(merged, out_dir="static", who="team2") Json_Team_Generation(merged, out_dir="static", who="team2")
Team_Both_Stat(merged, out_dir="static") Team_Both_Stat(merged, out_dir="static")
Referee(merged, out_dir="static") Referee(merged, out_dir="static")
Scores_Quarter(merged, out_dir="static") Scores_Quarter(merged, out_dir="static")
status_online_func(merged, out_dir="static")
# print(merged) # print(merged)
logger.info( logger.info(
f"Сегодня у {team} нет игры.\nПоследняя сыгранная: gameID={game_id}.\nМониторинг не запускаю." f"Сегодня у {team} нет игры.\nПоследняя сыгранная: gameID={game_id}.\nМониторинг не запускаю."
@@ -1918,12 +2051,27 @@ def main():
) )
rollover_thread.start() rollover_thread.start()
# 1.1) турнирная таблица
threads = [
threading.Thread(
target=Standing_func,
args=(league, season, args.lang, stop_event),
name="standings",)]
for t in threads:
t.start()
logger.debug(f"Поток {t.name} запущен.")
# Держим главный поток живым # Держим главный поток живым
try: try:
while True: while True:
time.sleep(1) time.sleep(1)
except KeyboardInterrupt: except KeyboardInterrupt:
logger.info("Завершение по Ctrl+C…") logger.info("Завершение по Ctrl+C…")
stop_event.set()
for t in threads:
t.join()
logger.debug(f"Поток {t.name} завершён.")
finally: finally:
stop_event.set() stop_event.set()
monitor_mgr.stop() monitor_mgr.stop()