Compare commits

...

14 Commits

Author SHA1 Message Date
Alexey Barabanov
5994e18efa Не ябучий 3й питон!!! 2025-11-13 17:42:29 +03:00
Alexey Barabanov
5205dd5c5c lgkllhdfg 2025-11-13 17:40:52 +03:00
Alexey Barabanov
ead3683e4c коминт 2025-11-13 17:26:09 +03:00
Alexey Barabanov
8f300e0154 ОПЕЧАТКА 2025-11-13 16:48:10 +03:00
6e92022e67 коммит 2025-11-13 15:57:12 +03:00
74801ad710 Merge branch 'main' of https://git.tvstart.ru/ychernenko/KHL 2025-11-13 15:54:42 +03:00
28b0f09ec2 что то поменял, но нужно проверить 2025-11-13 15:53:43 +03:00
Alexey Barabanov
c60caaa8aa Jgtxfnrf 2025-11-13 15:30:27 +03:00
Alexey Barabanov
9cbf32f831 Испровление опечатки 2025-11-13 15:30:09 +03:00
Alexey Barabanov
d4d9584bb3 Поменяли взоиможействие с файлом окружения.
Поменяли что береться из файла окружения
2025-11-13 15:28:14 +03:00
Alexey Barabanov
216138ceed Изменения в содании сервиса.
Добавлены:
Лимиты
Логирование
Поведение при перезапуске
Работа с виртуальным окрыжением и файлом окружения
2025-11-13 15:09:50 +03:00
Юрий Черненко
0d06097181 версия с чтением и передачей бинарного файла 2025-11-11 19:46:41 +03:00
71f1e62630 поправил gitignore 2025-11-11 15:18:41 +03:00
d120630548 Remove __pycache__ from history 2025-11-11 15:17:56 +03:00
5 changed files with 246 additions and 141 deletions

7
.gitignore vendored
View File

@@ -1,5 +1,8 @@
.env
/.venv
logs/
/logs/*
/__pycache__/*
*.pyc
__pycache__/
*.pyc
*.pyd
*.pyo

View File

@@ -0,0 +1,4 @@
## Проверка логов
```shell
sudo journalctl -t KHL
```

Binary file not shown.

View File

@@ -11,6 +11,7 @@ NC='\033[0m' # No Color
REPO_URL="https://git.tvstart.ru/ychernenko/KHL.git"
TARGET_DIR="/root/KHL"
SERVICE_NAME="khl-data.service"
TARGET_ENV="/mnt/khl/.env"
show_help() {
echo "Использование: $0 -r <релиз> [-h]"
@@ -41,7 +42,7 @@ log_debug() {
# Функция проверки зависимостей системы
check_dependencies() {
local deps=("git" "python3" "pip3" "netstat" "systemctl")
local deps=("git" "python3" "pip" "netstat" "systemctl")
local missing=()
for dep in "${deps[@]}"; do
@@ -77,13 +78,13 @@ install_packages() {
exit 1
fi
if ! command -v pip3 &> /dev/null; then
log_error "pip3 не установлен!"
if ! command -v pip &> /dev/null; then
log_error "pip не установлен!"
exit 1
fi
log_info "Версия Python: $(python3 --version)"
log_info "Версия pip: $(pip3 --version)"
log_info "Версия pip: $(pip --version)"
}
# Функция загрузки кода
@@ -206,25 +207,38 @@ create_systemd_service() {
fi
# Формируем команду для data сервиса
local data_command="$TARGET_DIR/.venv/bin/python3 $TARGET_DIR/get_data.py"
local data_service_file="/etc/systemd/system/$SERVICE_NAME"
log_info "Создание файла сервиса: $data_service_file"
cat > "$data_service_file" << EOF
[Unit]
Description=KHL Data Service
Documentation=https://git.tvstart.ru/ychernenko/KHL
After=network.target
Wants=network.target
[Service]
Type=simple
User=root
WorkingDirectory=$TARGET_DIR
Environment=PATH=$TARGET_DIR/.venv/bin
ExecStart=$data_command
Restart=always
RestartSec=30
EnvironmentFile=$TARGET_ENV
ExecStart=python3 $TARGET_DIR/get_data.py
# Лимиты ресурсов
MemoryMax=1G
CPUQuota=80%
# Логирование
StandardOutput=journal
StandardError=journal
SyslogIdentifier=KHL
# Поведение при перезапуске
Restart=always
RestartSec=10
StartLimitInterval=300
StartLimitBurst=5
[Install]
WantedBy=multi-user.target
@@ -489,6 +503,7 @@ main() {
log_info ""
log_info "Для просмотра логов:"
log_info " journalctl -u $SERVICE_NAME -f"
log_info " journalctl -t KHL -f"
log_info ""
log_info "Управление сервисом:"
log_info " Перезапуск: systemctl restart $SERVICE_NAME"

View File

@@ -1,5 +1,5 @@
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import Response, JSONResponse, HTMLResponse
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.responses import Response, JSONResponse, HTMLResponse, StreamingResponse
from fastapi.encoders import jsonable_encoder
import pandas as pd
import requests, io, os
@@ -12,40 +12,37 @@ import time
from contextlib import asynccontextmanager
import numpy as np
import nasio
import logging
import logging.config
import platform
import json
from pprint import pprint
# --- Глобальные переменные ---
selected_game_id: int | None = None
current_tournament_id: int | None = None # будем обновлять при загрузке расписания
current_season: str | None = None # будем обновлять при загрузке расписания
latest_game_data: dict | None = None # сюда кладём последние данные по матчу
current_tournament_id: int | None = None # будем обновлять при загрузке расписания
current_season: str | None = None # будем обновлять при загрузке расписания
latest_game_data: dict | None = None # сюда кладём последние данные по матчу
latest_game_error: str | None = None
_latest_lock = Lock()
_stop_event = Event()
_worker_thread: Thread | None = None
# Загружаем переменные из .env
if load_dotenv(dotenv_path="/mnt/khl/.env",verbose=True):
print("Добавить в лог что был найден файл окружения!!")
pass
else:
load_dotenv()
print("Добавить в лог что не был найден файл окружения!!")
pprint(f"Локальный файл окружения ={load_dotenv(verbose=True)}")
api_user = os.getenv("API_USER")
api_pass = os.getenv("API_PASS")
league = os.getenv("LEAGUE")
api_base_url = os.getenv("API_BASE_URL")
POLL_SEC = int(os.getenv("GAME_POLL_SECONDS"))
SERVER_NAME = os.getenv("SYNO_URL")
USER = os.getenv("SYNO_USERNAME")
PASSWORD = os.getenv("SYNO_PASSWORD")
PATH = "/team-folders/GFX/Hockey/KHL/Soft/MATCH.xlsm"
PATH = f'{os.getenv("SYNO_PATH")}MATCH.xlsm'
def load_today_schedule():
"""Возвращает DataFrame матчей на сегодня с нужными колонками (или пустой DF)."""
url_tournaments = "http://stat2tv.khl.ru/tournaments.xml"
url_tournaments = f"{api_base_url}tournaments.xml"
r = requests.get(
url_tournaments, auth=HTTPBasicAuth(api_user, api_pass), verify=False
)
@@ -69,12 +66,22 @@ def load_today_schedule():
global current_tournament_id, current_season
current_tournament_id = tournament_id
current_season = season
url_schedule = f"http://stat2tv.khl.ru/{tournament_id}/schedule-{tournament_id}.xml"
url_schedule = f"{api_base_url}{tournament_id}/schedule-{tournament_id}.xml"
r = requests.get(url_schedule, auth=HTTPBasicAuth(api_user, api_pass), verify=False)
schedule_df = pd.read_xml(io.StringIO(r.text))
# Нужные колонки (скорректируй под реальные имена из XML)
needed_columns = ["id", "date", "time", "homeName_en", "visitorName_en", "arena_en", "arena_city_en", "homeCity_en", "visitorCity_en"]
needed_columns = [
"id",
"date",
"time",
"homeName_en",
"visitorName_en",
"arena_en",
"arena_city_en",
"homeCity_en",
"visitorCity_en",
]
exist = [c for c in needed_columns if c in schedule_df.columns]
schedule_df = schedule_df[exist].copy()
@@ -117,17 +124,14 @@ def load_today_schedule():
def _build_game_url(tournament_id: int, game_id: int) -> str:
# URL по аналогии с расписанием: .../{tournament_id}/json_en/{game_id}.json
# Если у тебя другой шаблон — просто поменяй строку ниже.
return f"http://stat2tv.khl.ru/{tournament_id}/json_en/{game_id}.json"
return f"{api_base_url}{tournament_id}/json_en/{game_id}.json"
def _fetch_game_once(tournament_id: int, game_id: int) -> dict:
"""Один запрос к API матча -> чистый JSON из API."""
url = _build_game_url(tournament_id, game_id)
r = requests.get(
url,
auth=HTTPBasicAuth(api_user, api_pass),
verify=False,
timeout=10
url, auth=HTTPBasicAuth(api_user, api_pass), verify=False, timeout=10
)
r.raise_for_status()
@@ -183,17 +187,18 @@ async def lifespan(app: FastAPI):
_worker_thread.join(timeout=2)
print("🛑 Background thread stopped")
app = FastAPI(
lifespan=lifespan,
docs_url=None, # ❌ отключает /docs
redoc_url=None, # ❌ отключает /redoc
openapi_url=None # ❌ отключает /openapi.json
docs_url=None, # ❌ отключает /docs
redoc_url=None, # ❌ отключает /redoc
openapi_url=None, # ❌ отключает /openapi.json
)
@app.get("/games")
async def games():
df = load_today_schedule()
if df.empty:
return JSONResponse({"message": "Сегодня матчей нет"})
@@ -202,8 +207,6 @@ async def games():
return Response(content=json_schedule, media_type="application/json")
@app.get("/select")
async def select():
df = load_today_schedule()
@@ -336,13 +339,15 @@ async def get_selected_game():
async def game_url():
if not (selected_game_id and current_tournament_id):
return JSONResponse({"message": "game_id или tournament_id не задан"})
return JSONResponse({
"url": _build_game_url(current_tournament_id, selected_game_id),
"game_id": selected_game_id,
"tournament_id": current_tournament_id
})
return JSONResponse(
{
"url": _build_game_url(current_tournament_id, selected_game_id),
"game_id": selected_game_id,
"tournament_id": current_tournament_id,
}
)
# @app.get("/info")
# async def info():
# if selected_game_id:
@@ -356,7 +361,9 @@ async def game_data():
return JSONResponse(latest_game_data)
if latest_game_error:
return JSONResponse({"error": latest_game_error}, status_code=502)
return JSONResponse({"message": "Ещё нет данных. Выберите матч и подождите первое обновление."})
return JSONResponse(
{"message": "Ещё нет данных. Выберите матч и подождите первое обновление."}
)
@app.get("/referee")
@@ -383,15 +390,14 @@ async def referee():
return data_referees
async def team(who:str):
""""who: A - домашняя команда, B - гостевая"""
async def team(who: str):
""" "who: A - домашняя команда, B - гостевая"""
with _latest_lock:
lgd = latest_game_data
# print(lgd)
if not lgd or "data" not in lgd:
return [{"details":"Нет данных по матчу!"}]
return [{"details": "Нет данных по матчу!"}]
players1_temp = lgd["data"]["players"][who]
players1 = []
players1_f = []
@@ -426,7 +432,7 @@ async def team(who:str):
position = "forward"
elif players1_temp[player]["ps"] == "d":
position = "defenseman"
data_with_number = {
"number": player,
"NameSurnameGFX": names_new + " " + lastname1,
@@ -462,7 +468,7 @@ async def team(who:str):
}
players1_g.append(data_with_number2)
goaltenders1.append(data_with_number2)
def make_empty(example_list):
if not example_list:
return {}
@@ -479,20 +485,22 @@ async def team(who:str):
players1_f.append(empty_f.copy())
while len(players1_g) < 3:
players1_g.append(empty_g.copy())
players1 = players1_d + players1_f + players1_g
# print(len(players1))
return players1
@app.get("/team1")
async def team1():
return await team("A")
@app.get("/team2")
async def team2():
return await team("B")
# 👉 метка для первой строки (period row)
def _period_label(period: str | int) -> str:
s = str(period).strip().upper()
@@ -516,6 +524,7 @@ def _period_label(period: str | int) -> str:
return "FINAL"
return ""
# 👉 сортировка периодов: 1,2,3, затем OT/OT2/...
def _period_sort_key(k: str) -> tuple[int, int]:
s = str(k).strip().upper()
@@ -578,19 +587,25 @@ def format_team_stat(team1: dict, team2: dict, period: str | None = None) -> lis
teams = [{k: str(v) for k, v in t.items()} for t in [team1, team2]]
keys = list(teams[0].keys())
formatted = []
if period is not None:
formatted.append({
"name0": "period",
"name1": str(period),
"name2": "",
"StatParameterGFX": _period_label(period) or "Period"
})
formatted.append(
{
"name0": "period",
"name1": str(period),
"name2": "",
"StatParameterGFX": _period_label(period) or "Period",
}
)
for key in keys:
row = {"name0": key, "name1": teams[0].get(key, ""), "name2": teams[1].get(key, "")}
row = {
"name0": key,
"name1": teams[0].get(key, ""),
"name2": teams[1].get(key, ""),
}
# подписи
for code, eng, _ru in stat_list:
if key == code:
@@ -608,6 +623,7 @@ def format_team_stat(team1: dict, team2: dict, period: str | None = None) -> lis
def flip(s: str) -> str:
parts = s.split()
return f"{parts[1]} {parts[0]}" if len(parts) >= 2 else s
r["name1"] = flip(r["name1"])
r["name2"] = flip(r["name2"])
@@ -622,11 +638,13 @@ def _iter_period_pairs(teams_periods: dict):
"""
a = teams_periods.get("A", {})
b = teams_periods.get("B", {})
def _key(k):
try:
return int(k)
except (TypeError, ValueError):
return k
for k in sorted(a.keys(), key=_key):
if k in b:
yield k, a[k], b[k]
@@ -638,22 +656,20 @@ def _build_all_stats(payload: dict) -> dict:
"""
total_a = payload["teams"]["A"]
total_b = payload["teams"]["B"]
result = {
"total": format_team_stat(total_a, total_b),
"periods": []
}
result = {"total": format_team_stat(total_a, total_b), "periods": []}
for period_key, a_stat, b_stat in _iter_period_pairs(payload["teams_periods"]):
result["periods"].append({
"period": period_key,
"stats": format_team_stat(a_stat, b_stat)
})
result["periods"].append(
{"period": period_key, "stats": format_team_stat(a_stat, b_stat)}
)
return result
@app.get("/teams/stats")
async def teams_stats(
scope: str = Query("all", pattern="^(all|total|period)$"),
n: str | None = Query(None, description="Номер периода (строка или число) при scope=period"),
n: str | None = Query(
None, description="Номер периода (строка или число) при scope=period"
),
):
"""
Все-в-одном: GET /teams/stats?scope=all
@@ -671,7 +687,9 @@ async def teams_stats(
payload = lgd["data"]
if scope == "total":
data = format_team_stat(payload["teams"]["A"], payload["teams"]["B"], period="Total")
data = format_team_stat(
payload["teams"]["A"], payload["teams"]["B"], period="Total"
)
return JSONResponse({"scope": "total", "data": data})
if scope == "period":
@@ -688,21 +706,23 @@ async def teams_stats(
a = payload["teams_periods"]["A"].get(period_key)
b = payload["teams_periods"]["B"].get(period_key)
if a is None or b is None:
raise HTTPException(status_code=404, detail=f"Период {period_key} не найден.")
return JSONResponse({
"scope": "period",
"period": period_key,
"is_current": period_key == _current_period_key(payload),
"data": format_team_stat(a, b, period=period_key)
})
raise HTTPException(
status_code=404, detail=f"Период {period_key} не найден."
)
return JSONResponse(
{
"scope": "period",
"period": period_key,
"is_current": period_key == _current_period_key(payload),
"data": format_team_stat(a, b, period=period_key),
}
)
# scope == "all"
cur = _current_period_key(payload)
return JSONResponse({
"scope": "all",
"current_period": cur,
"data": _build_all_stats(payload)
})
return JSONResponse(
{"scope": "all", "current_period": cur, "data": _build_all_stats(payload)}
)
def _norm_name(s: str | None) -> str:
@@ -711,8 +731,16 @@ def _norm_name(s: str | None) -> str:
return ""
return str(s).strip().casefold()
def _load_buf():
buf = nasio.load_bio(user=USER, password=PASSWORD,
nas_ip=SERVER_NAME, nas_port="443", path=PATH)
if isinstance(buf, (bytes, bytearray, memoryview)):
buf = io.BytesIO(buf)
buf.seek(0)
return buf
@app.get("/info")
async def info():
async def info(format: str = "xlsx", sheet: str = "TEAMS"):
# 1) Проверяем, выбран ли матч
global current_season
if not selected_game_id:
@@ -721,73 +749,128 @@ async def info():
# 2) Берём расписание и ищем строку по выбранному ID
df = load_today_schedule()
if df.empty:
return JSONResponse({"message": "Сегодня матчей нет", "selected_id": selected_game_id})
return JSONResponse(
{"message": "Сегодня матчей нет", "selected_id": selected_game_id}
)
# безопасно приводим id к int и ищем
try:
row = df.loc[df["id"].astype(int) == int(selected_game_id)].iloc[0]
except Exception:
return JSONResponse({"message": "Выбранный матч не найден в расписании на сегодня",
"selected_id": selected_game_id}, status_code=404)
return JSONResponse(
{
"message": "Выбранный матч не найден в расписании на сегодня",
"selected_id": selected_game_id,
},
status_code=404,
)
home_name = str(row.get("homeName_en", "")).strip()
away_name = str(row.get("visitorName_en", "")).strip()
# 3) Подтягиваем справочник команд из Excel (лист TEAMS)
teams_df = nasio.load_formatted(
user=USER,
password=PASSWORD,
nas_ip=SERVER_NAME,
nas_port="443",
path=PATH,
sheet="TEAMS"
)
src = _load_buf()
# Оставляем только полезные поля (подгони под свой файл)
keep = [ "Team", "Logo", "Short", "HexPodl", "HexBase", "HexText" ]
keep = [c for c in keep if c in teams_df.columns]
teams_df = teams_df.loc[:, keep].copy()
if format == "xlsx":
# читаем нужный лист из исходного XLSM
df = pd.read_excel(src, sheet_name=sheet, engine="openpyxl")
# 4) Нормализованные ключи для джоина по имени
teams_df["__key"] = teams_df["Team"].apply(_norm_name)
# пишем НОВЫЙ XLSX (без макросов) — это то, что понимает vMix
out = io.BytesIO()
with pd.ExcelWriter(out, engine="openpyxl") as writer:
df.to_excel(writer, sheet_name=sheet, index=False)
out.seek(0)
def _pick_team_info(name: str) -> dict:
key = _norm_name(name)
hit = teams_df.loc[teams_df["__key"] == key]
if hit.empty:
# не нашли точное совпадение — вернём только название
return {"Team": name}
rec = hit.iloc[0].to_dict()
rec.pop("__key", None)
# заменим NaN/inf на None, чтобы JSON не падал
for k, v in list(rec.items()):
if pd.isna(v) or v in (np.inf, -np.inf):
rec[k] = None
return rec
return StreamingResponse(
out,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
# стабильное имя файла, чтобы vMix не путался
"Content-Disposition": "inline; filename=vmix.xlsx",
# отключаем кэш браузера/прокси, vMix сам опрашивает по интервалу
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
},
)
home_info = _pick_team_info(home_name)
away_info = _pick_team_info(away_name)
date_obj = datetime.strptime(row.get("datetime_str", ""), "%d.%m.%Y %H:%M")
try:
full_format = date_obj.strftime("%B %-d, %Y")
except ValueError:
full_format = date_obj.strftime("%B %#d, %Y")
elif format == "csv":
df = pd.read_excel(src, sheet_name=sheet, engine="openpyxl")
csv_bytes = df.to_csv(index=False, encoding="utf-8-sig").encode("utf-8")
return StreamingResponse(
io.BytesIO(csv_bytes),
media_type="text/csv; charset=utf-8",
headers={
"Content-Disposition": "inline; filename=vmix.csv",
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
},
)
elif format == "json":
df = pd.read_excel(src, sheet_name=sheet, engine="openpyxl")
payload = json.dumps(df.to_dict(orient="records"), ensure_ascii=False)
return Response(
content=payload,
media_type="application/json; charset=utf-8",
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
},
)
payload = [{
"selected_id": int(selected_game_id),
"tournament_id": int(current_tournament_id) if current_tournament_id else None,
"datetime": str(full_format),
"arena": str(row.get("arena_en", "")),
"arena_city": str(row.get("arena_city_en", "")),
"home": home_info,
"home_city": str(row.get("homeCity_en", "")),
"away": away_info,
"away_city": str(row.get("visitorCity_en", "")),
"season": current_season,
}]
return Response("Unsupported format", status_code=400)
# # Оставляем только полезные поля (подгони под свой файл)
# keep = ["Team", "Logo", "Short", "HexPodl", "HexBase", "HexText"]
# keep = [c for c in keep if c in teams_df.columns]
# teams_df = teams_df.loc[:, keep].copy()
# # 4) Нормализованные ключи для джоина по имени
# teams_df["__key"] = teams_df["Team"].apply(_norm_name)
# def _pick_team_info(name: str) -> dict:
# key = _norm_name(name)
# hit = teams_df.loc[teams_df["__key"] == key]
# if hit.empty:
# # не нашли точное совпадение — вернём только название
# return {"Team": name}
# rec = hit.iloc[0].to_dict()
# rec.pop("__key", None)
# # заменим NaN/inf на None, чтобы JSON не падал
# for k, v in list(rec.items()):
# if pd.isna(v) or v in (np.inf, -np.inf):
# rec[k] = None
# return rec
# home_info = _pick_team_info(home_name)
# away_info = _pick_team_info(away_name)
# date_obj = datetime.strptime(row.get("datetime_str", ""), "%d.%m.%Y %H:%M")
# try:
# full_format = date_obj.strftime("%B %-d, %Y")
# except ValueError:
# full_format = date_obj.strftime("%B %#d, %Y")
# payload = [
# {
# "selected_id": int(selected_game_id),
# "tournament_id": (
# int(current_tournament_id) if current_tournament_id else None
# ),
# "datetime": str(full_format),
# "arena": str(row.get("arena_en", "")),
# "arena_city": str(row.get("arena_city_en", "")),
# "home": home_info,
# "home_city": str(row.get("homeCity_en", "")),
# "away": away_info,
# "away_city": str(row.get("visitorCity_en", "")),
# "season": current_season,
# }
# ]
# return JSONResponse(content=payload)
# except Exception as ex:
# pprint(ex)
return JSONResponse(content=payload)
if __name__ == "__main__":
uvicorn.run(