From fadcc6cd8b807867b63ef476d4e9d236dd4ea41d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=AE=D1=80=D0=B8=D0=B9=20=D0=A7=D0=B5=D1=80=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=BA=D0=BE?= Date: Wed, 5 Nov 2025 18:32:26 +0300 Subject: [PATCH] part1 --- .gitignore | 1 + __pycache__/get_data.cpython-312.pyc | Bin 0 -> 14585 bytes get_data.py | 330 +++++++++++++++++++++++++++ requirements.txt | 3 +- 4 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 __pycache__/get_data.cpython-312.pyc diff --git a/.gitignore b/.gitignore index e69de29..2eea525 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/__pycache__/get_data.cpython-312.pyc b/__pycache__/get_data.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3cab7a8b372afc7e48bd7f0fa10cb6182666d4f3 GIT binary patch literal 14585 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 literal 0 HcmV?d00001 diff --git a/get_data.py b/get_data.py index e69de29..fb373b2 100644 --- a/get_data.py +++ b/get_data.py @@ -0,0 +1,330 @@ +from fastapi import FastAPI +from fastapi.responses import Response, JSONResponse, HTMLResponse +import pandas as pd +import requests, io, os +from requests.auth import HTTPBasicAuth +from dotenv import load_dotenv +from datetime import datetime +import uvicorn +from threading import Thread, Event, Lock +import time +from contextlib import asynccontextmanager + + +# --- Глобальные переменные --- +selected_game_id: int | None = None +current_tournament_id: int | 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 +load_dotenv() +api_user = os.getenv("API_USER") +api_pass = os.getenv("API_PASS") +league = os.getenv("LEAGUE") +POLL_SEC = int(os.getenv("GAME_POLL_SECONDS")) + + + +def load_today_schedule(): + """Возвращает DataFrame матчей на сегодня с нужными колонками (или пустой DF).""" + url_tournaments = "http://stat2tv.khl.ru/tournaments.xml" + r = requests.get( + url_tournaments, auth=HTTPBasicAuth(api_user, api_pass), verify=False + ) + df = pd.read_xml(io.StringIO(r.text)) + + df["startDate"] = pd.to_datetime(df["startDate"], errors="coerce") + df["endDate"] = pd.to_datetime(df["endDate"], errors="coerce") + now = datetime.now() + + filtered = df[ + (df["level"] == league) + & (df["startDate"] <= now) + & (df["endDate"] >= now) + & (df["seasonPart"] == "regular") + ] + if filtered.empty: + return pd.DataFrame() + + tournament_id = int(filtered.iloc[0]["id"]) + global current_tournament_id + current_tournament_id = tournament_id + url_schedule = f"http://stat2tv.khl.ru/{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"] + exist = [c for c in needed_columns if c in schedule_df.columns] + schedule_df = schedule_df[exist].copy() + + # Преобразуем дату и время + schedule_df["date"] = pd.to_datetime(schedule_df["date"], errors="coerce") + today = now.date() + schedule_today = schedule_df[schedule_df["date"].dt.date == today].copy() + + # --- Нормализуем время и строим единый datetime для сортировки --- + # 1) берём из time только HH:MM (на случай '19:30', '19:30:00', '19:30 MSK', и т.п.) + time_clean = ( + schedule_today.get("time") + .astype(str) + .str.extract(r"(?P\d{1,2}:\d{2})", expand=True)["hhmm"] + ) + + # 2) собираем строку "YYYY-MM-DD HH:MM" и парсим в Timestamp + date_str = schedule_today["date"].dt.strftime("%Y-%m-%d") + kickoff_str = (date_str + " " + time_clean.fillna("")).str.strip() + schedule_today["kickoff_dt"] = pd.to_datetime(kickoff_str, errors="coerce") + + # 3) сортировка: сначала по времени (NaT в конец), потом по id + schedule_today = schedule_today.sort_values( + by=["kickoff_dt", "id"], ascending=[True, True], na_position="last" + ) + + # 4) человекочитаемая строка даты/времени для таблицы + schedule_today["datetime_str"] = schedule_today["kickoff_dt"].dt.strftime( + "%d.%m.%Y %H:%M" + ) + # если time отсутствует и kickoff_dt = NaT — показываем просто дату + mask_nat = schedule_today["kickoff_dt"].isna() + schedule_today.loc[mask_nat, "datetime_str"] = schedule_today.loc[ + mask_nat, "date" + ].dt.strftime("%d.%m.%Y") + + return schedule_today + + +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" + +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 + ) + r.raise_for_status() + + # Пробуем распарсить JSON + try: + data = r.json() + except ValueError: + # если это не JSON — вернём текст + data = {"raw": r.text} + + return {"url": url, "json": data} + + +def _game_poll_worker(): + """Фоновый цикл: опрашивает API для выбранного game_id.""" + global latest_game_data, latest_game_error + while not _stop_event.is_set(): + gid = selected_game_id + tid = current_tournament_id + if gid and tid: + try: + data = _fetch_game_once(tid, gid) + with _latest_lock: + latest_game_data = { + "tournament_id": tid, + "game_id": gid, + "fetched_at": datetime.now().isoformat(), + "data": data, + } + latest_game_error = None + except Exception as e: + with _latest_lock: + latest_game_error = f"{type(e).__name__}: {e}" + # Ждём интервал (с возможностью ранней остановки) + _stop_event.wait(POLL_SEC) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Запускаем и останавливаем поток чтения данных при старте/остановке приложения.""" + global _worker_thread + _stop_event.clear() + _worker_thread = Thread(target=_game_poll_worker, daemon=True) + _worker_thread.start() + print("✅ Background thread started") + + # Отдаём управление FastAPI + yield + + # Останавливаем при завершении + _stop_event.set() + if _worker_thread.is_alive(): + _worker_thread.join(timeout=2) + print("🛑 Background thread stopped") + +app = FastAPI(lifespan=lifespan) + + +@app.get("/games") +async def games(): + df = load_today_schedule() + if df.empty: + return JSONResponse({"message": "Сегодня матчей нет"}) + + json_schedule = df.to_json(orient="records", force_ascii=False, date_format="iso") + return Response(content=json_schedule, media_type="application/json") + + +@app.get("/games/html") +async def games_html(): + df = load_today_schedule() + if df.empty: + return HTMLResponse( + "

Сегодня матчей нет

" + ) + + # Строим строки таблицы + rows_html = [] + for _, row in df.iterrows(): + gid = int(row["id"]) + home = row.get("homeName_en", "") + away = row.get("visitorName_en", "") + when = row.get("datetime_str", "") + arena = row.get("arena", "") + rows_html.append( + f""" + + {gid} + {when} + {home} + {away} + {arena} + + + """ + ) + + # ✅ Весь HTML, включая JS, внутри тройных кавычек + html = f""" + + + +Выбор матча + +

Матчи сегодня

+ + + + + + + + {''.join(rows_html)} + +
IDДата/времяХозяеваГостиАрена
+
Ничего не выбрано
+ + +""" + return HTMLResponse(html) + + +@app.post("/select-game/{game_id}") +async def select_game(game_id: int): + global selected_game_id, latest_game_data, latest_game_error + selected_game_id = game_id + + # моментально подтянуть первое состояние, если известен турнир + if current_tournament_id: + try: + data = _fetch_game_once(current_tournament_id, selected_game_id) + with _latest_lock: + latest_game_data = { + "tournament_id": current_tournament_id, + "game_id": selected_game_id, + "fetched_at": datetime.now().isoformat(), + "data": data, + } + latest_game_error = None + except Exception as e: + with _latest_lock: + latest_game_error = f"{type(e).__name__}: {e}" + + return JSONResponse({"selected_id": selected_game_id}) + + +@app.get("/selected-game") +async def get_selected_game(): + return JSONResponse({"selected_id": selected_game_id}) + + +@app.get("/game/url") +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 + }) + +@app.get("/game/data") +async def game_data(): + with _latest_lock: + if latest_game_data: + return JSONResponse(latest_game_data) + if latest_game_error: + return JSONResponse({"error": latest_game_error}, status_code=502) + return JSONResponse({"message": "Ещё нет данных. Выберите матч и подождите первое обновление."}) + +if __name__ == "__main__": + uvicorn.run( + "get_data:app", host="0.0.0.0", port=8000, reload=True, log_level="debug" + ) diff --git a/requirements.txt b/requirements.txt index 85d6550..775676b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ numpy>=1.24.0 fastapi>=0.115.0 uvicorn>=0.30.0 requests>=2.31.0 -python-telegram-handler \ No newline at end of file +python-telegram-handler +python-dotenv>=1.1.0 \ No newline at end of file