From 57907fd86bbfff8334bd907ad89d0cd5b8399dd4 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: Thu, 2 Jul 2026 12:46:29 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=BB=D1=8F=20=D0=9A=D1=83=D0=B1?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=A0=D0=BE=D1=81=D1=81=D0=B8=D0=B8=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B4=D0=B5=D0=BB=D0=B0=D0=BD=D1=8B=20=D0=B2?= =?UTF-8?q?=D1=81=D0=B5=20=D0=BF=D0=B0=D1=80=D1=81=D0=B5=D1=80=D1=8B=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B8=20=D0=B8?= =?UTF-8?q?=D0=B7=20=D0=B1=D0=B0=D0=B7=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent.py | 4 +- app.py | 196 +++++++++-- parsers/parser_game.py | 5 +- parsers/parser_players.py | 40 ++- parsers/parser_schedule.py | 23 +- parsers/parser_sources.py | 230 +++++++++++++ parsers/parser_standings.py | 15 +- parsers/parser_teams.py | 64 ++-- repositories/match_repository.py | 26 +- repositories/match_session_repository.py | 15 +- repositories/project_settings_repository.py | 361 ++++++++++++++++++++ repositories/team_repository.py | 7 +- scheduler.py | 4 +- services/env_settings_service.py | 148 ++++++++ services/project_settings_service.py | 142 ++++++++ services/schedule_service.py | 3 +- services/vmix_json_service.py | 173 ++++++---- source_config.py | 32 ++ sql/003_matches_source_key.sql | 4 + sql/004_project_settings.sql | 79 +++++ sql/005_parser_sources_photo_base_path.sql | 14 + static/script.js | 20 +- templates/admin_db_index.html | 314 ++++++++++++++++- templates/admin_db_player_edit.html | 6 +- templates/admin_db_team_edit.html | 25 +- templates/admin_db_teams.html | 17 +- vMix.zip | Bin 0 -> 2338838 bytes vmix/vmix_service.py | 28 +- wfl.sql | 83 +++++ 29 files changed, 1838 insertions(+), 240 deletions(-) create mode 100644 parsers/parser_sources.py create mode 100644 repositories/project_settings_repository.py create mode 100644 services/env_settings_service.py create mode 100644 services/project_settings_service.py create mode 100644 source_config.py create mode 100644 sql/003_matches_source_key.sql create mode 100644 sql/004_project_settings.sql create mode 100644 sql/005_parser_sources_photo_base_path.sql create mode 100644 vMix.zip diff --git a/agent.py b/agent.py index 34c87ec..2d06577 100644 --- a/agent.py +++ b/agent.py @@ -13,8 +13,8 @@ import requests import websockets VMIX_API = "http://127.0.0.1:8088/api" -WS_BASE = "wss://wfl.tvstart.ru/ws/vmix-client" -# WS_BASE = "ws://127.0.0.1:8000/ws/vmix-client" +# WS_BASE = "wss://wfl.tvstart.ru/ws/vmix-client" +WS_BASE = "ws://127.0.0.1:8000/ws/vmix-client" CLIENT_ID = f"vmix-{socket.gethostname().lower()}-{uuid.uuid4().hex[:6]}" POLL_INTERVAL = 3 diff --git a/app.py b/app.py index 1c5a915..b388481 100644 --- a/app.py +++ b/app.py @@ -33,6 +33,10 @@ from parsers.parser_game import run_parser_game from parsers.parser_players import run_parser_players from parsers.parser_schedule import run_parser_schedule from parsers.parser_standings import run_parser_standings +from parsers.parser_teams import run_parser_teams +from parsers.parser_sources import build_empty_photo_path, list_parser_sources, get_default_source_key, get_parser_source +from services.project_settings_service import build_project_settings_context, save_project_settings +from repositories.project_settings_repository import ensure_project_settings_tables from db import get_connection @@ -55,7 +59,7 @@ from repositories.match_session_repository import ( from repositories.match_view_repository import get_match_lineups_grouped from vmix.vmix_service import build_vmix_project_bytes, build_vmix_filename from repositories.match_referee_repository import get_match_referees -from repositories.match_repository import get_tour_schedule_by_match_id +from repositories.match_repository import get_tour_schedule_by_match_id, ensure_match_source_key_column from repositories.standings_repository import get_standings_by_match_id from repositories.match_coach_repository import get_match_coaches_grouped from repositories.player_repository import ( @@ -186,7 +190,9 @@ def start_scheduler(): import threading try: + ensure_project_settings_tables() ensure_player_photo_enabled_column() + ensure_match_source_key_column() except Exception: traceback.print_exc() @@ -700,10 +706,13 @@ def download_vmix_project(request: Request, session_token: str): operator_login = current_user.get("username") or None try: + source_key = session_row[21] if len(session_row) > 21 else None + vmix_bytes = build_vmix_project_bytes( session_token=session_token, match_id=session_row[1], operator_login=operator_login, + source_key=source_key, ) filename = build_vmix_filename(session_row) @@ -852,16 +861,6 @@ def close_session(session_token: str): return RedirectResponse(url="/admin/matches", status_code=303) -@app.get("/admin/db", response_class=HTMLResponse) -def admin_db_index(request: Request): - return templates.TemplateResponse( - name="admin_db_index.html", - request=request, - context={ - "current_user": getattr(request.state, "current_user", None), - }, - ) - @app.get("/admin/db/players", response_class=HTMLResponse) def admin_db_players( @@ -1459,6 +1458,12 @@ EMPTY_PLAYER = { } +def build_empty_player(source_key=None): + item = EMPTY_PLAYER.copy() + item["photo"] = build_empty_photo_path(source_key) or EMPTY_PHOTO_PATH + return item + + def get_roster_data(session_token: str, name: str, count_player: int): session_row = get_match_session_by_token(session_token) if not session_row: @@ -1469,14 +1474,21 @@ def get_roster_data(session_token: str, name: str, count_player: int): home_team_name = session_row[14].replace("«", "").replace("»", "") away_team_id = session_row[17] away_team_name = session_row[18].replace("«", "").replace("»", "") + source_key = session_row[21] if len(session_row) > 21 else None # print(session_row) data = build_lineup_json( - match_id, home_team_id, away_team_id, name, home_team_name, away_team_name + match_id, + home_team_id, + away_team_id, + name, + home_team_name, + away_team_name, + source_key=source_key, ) players = data.get("players", []) - players.extend([EMPTY_PLAYER.copy() for _ in range(count_player - len(players))]) + players.extend([build_empty_player(source_key) for _ in range(count_player - len(players))]) data["players"] = players[:count_player] return data @@ -1687,7 +1699,13 @@ EMPTY_PLAYER_FORMATION = { } -def build_vmix_formation_response(rows, team_id, team_name): +def build_empty_player_formation(source_key=None): + item = EMPTY_PLAYER_FORMATION.copy() + item["photo"] = build_empty_photo_path(source_key) or EMPTY_PHOTO_PATH + return item + + +def build_vmix_formation_response(rows, team_id, team_name, source_key=None): result = [] for p in rows: @@ -1719,18 +1737,21 @@ def build_vmix_formation_response(rows, team_id, team_name): "first_name": p[4] if len(p) > 4 else "", "number": number, "position": position, + "photo": p[5] if len(p) > 5 else "", "photo_enabled": p[6] if len(p) > 6 else False, }, generated_photo=build_generated_player_photo_path( team_name=team_name, last_name=lastname, first_name=p[4] if len(p) > 4 else "", + source_key=source_key, ), fallback_team_id=team_id, + source_key=source_key, ), } ) - result.extend([EMPTY_PLAYER_FORMATION.copy() for _ in range(11 - len(result))]) + result.extend([build_empty_player_formation(source_key) for _ in range(11 - len(result))]) return result @@ -1743,8 +1764,9 @@ def vmix_home_formations(session_token: str): home_team_id = session_row[13] home_team = session_row[14].replace("«", "").replace("»", "") + source_key = session_row[21] if len(session_row) > 21 else None rows = get_vmix_team_formations(session_row, home_team_id) - return build_vmix_formation_response(rows, home_team_id, home_team) + return build_vmix_formation_response(rows, home_team_id, home_team, source_key=source_key) @app.get("/vmix/session/{session_token}/away-formations") @@ -1755,8 +1777,9 @@ def vmix_away_formations(session_token: str): away_team_id = session_row[17] away_team = session_row[18].replace("«", "").replace("»", "") + source_key = session_row[21] if len(session_row) > 21 else None rows = get_vmix_team_formations(session_row, away_team_id) - return build_vmix_formation_response(rows, away_team_id, away_team) + return build_vmix_formation_response(rows, away_team_id, away_team, source_key=source_key) @app.get("/vmix/session/{session_token}/scoreboard") @@ -2263,17 +2286,49 @@ def render_admin_db_index( parser_success: bool | None = None, update_output: str | None = None, update_success: bool | None = None, + settings_output: str | None = None, + settings_success: bool | None = None, ): + try: + parser_sources = list_parser_sources() + default_parser_source_key = get_default_source_key() + except Exception: + parser_sources = [ + { + "key": "SUPERLEAGUE", + "title": "Суперлига 2026", + "logo_base_path": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Teams Logos", + "photo_base_path": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo", + }, + { + "key": "RUSSIAN_CUP", + "title": "Кубок России 2026", + "logo_base_path": r"D:\Графика\ФУТБОЛ\Кубок России 2026\Teams Logos", + "photo_base_path": r"D:\Графика\ФУТБОЛ\Кубок России 2026\Photo", + }, + ] + default_parser_source_key = "SUPERLEAGUE" + + try: + project_settings = build_project_settings_context() + except Exception: + project_settings = {"storage_title": "База данных", "groups": [], "editable_keys": []} + return templates.TemplateResponse( name="admin_db_index.html", request=request, context={ "current_user": getattr(request.state, "current_user", None), + "parser_sources": parser_sources, + "default_parser_source_key": default_parser_source_key, "parser_output": parser_output, "parser_name": parser_name, "parser_success": parser_success, "update_output": update_output, "update_success": update_success, + "settings_output": settings_output, + "settings_success": settings_success, + "project_settings": project_settings, }, ) @@ -2284,45 +2339,78 @@ def admin_db_index(request: Request): @app.post("/admin/db/run-parser", response_class=HTMLResponse) -def admin_db_run_parser(request: Request, parser_name: str = Form(...)): +def admin_db_run_parser( + request: Request, + parser_name: str | None = Form(default=None), + parser_names: List[str] = Form(default=[]), + parser_source: str | None = Form(default=None), +): current_user = getattr(request.state, "current_user", None) if not current_user or current_user.get("role") != "admin": return RedirectResponse(url="/login", status_code=303) parser_map = { + "teams": ("Команды", run_parser_teams), "players": ("Игроки", run_parser_players), "schedule": ("Расписание", run_parser_schedule), "standings": ("Турнирка", run_parser_standings), } - parser_meta = parser_map.get(parser_name) - if not parser_meta: + selected_parser_names = list(parser_names or []) + if not selected_parser_names and parser_name: + selected_parser_names = [parser_name] + + selected_parser_names = [name for name in selected_parser_names if name in parser_map] + if not selected_parser_names: return render_admin_db_index( request, - parser_output="Неизвестный парсер.", - parser_name=parser_name, + parser_output="Выберите хотя бы один тип данных для парсинга.", + parser_name="Импорт данных", parser_success=False, ) - title, parser_func = parser_meta + try: + source = get_parser_source(parser_source) + except Exception as exc: + return render_admin_db_index( + request, + parser_output=str(exc), + parser_name="Импорт данных", + parser_success=False, + ) + + titles = [parser_map[name][0] for name in selected_parser_names] + title = f"{source['title']}: {', '.join(titles)}" + buffer = io.StringIO() success = True with contextlib.redirect_stdout(buffer), contextlib.redirect_stderr(buffer): - print( - f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Запуск парсера: {title}" - ) - try: - parser_func() - print( - f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Парсер завершён успешно" - ) - except Exception: - success = False - print( - f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Ошибка при выполнении парсера" - ) - print(traceback.format_exc()) + print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Источник: {source['title']}") + print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Запуск: {', '.join(titles)}") + + for selected_name in selected_parser_names: + item_title, parser_func = parser_map[selected_name] + print() + print(f"===== {item_title} =====") + try: + parser_func(source_key=source["key"]) + print( + f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {item_title}: успешно" + ) + except Exception: + success = False + print( + f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {item_title}: ошибка" + ) + print(traceback.format_exc()) + + if success: + print() + print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Импорт завершён успешно") + else: + print() + print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Импорт завершён с ошибками") output = buffer.getvalue().strip() or "Парсер не вернул сообщений." return render_admin_db_index( @@ -2334,6 +2422,40 @@ def admin_db_run_parser(request: Request, parser_name: str = Form(...)): +@app.post("/admin/db/project-settings", response_class=HTMLResponse) +async def admin_db_save_project_settings(request: Request): + current_user = getattr(request.state, "current_user", None) + if not current_user or current_user.get("role") != "admin": + return RedirectResponse(url="/login", status_code=303) + + try: + form = await request.form() + form_values = {key: str(value) for key, value in form.items()} + updated_keys = save_project_settings(form_values) + + output = [ + "Настройки проекта сохранены в базе данных.", + f"Сохранено полей: {len(updated_keys)}", + ] + if updated_keys: + output.append("Поля:") + output.extend([f"- {key}" for key in updated_keys]) + output.append("") + output.append(".env больше не используется для ссылок, сезонов, путей логотипов и фотографий.") + + return render_admin_db_index( + request, + settings_output="\n".join(output), + settings_success=True, + ) + except Exception: + return render_admin_db_index( + request, + settings_output="Ошибка при сохранении настроек проекта:\n" + traceback.format_exc(), + settings_success=False, + ) + + @app.post("/admin/db/add-update", response_class=HTMLResponse) def admin_db_add_update( request: Request, diff --git a/parsers/parser_game.py b/parsers/parser_game.py index 13d18bd..7fc198d 100644 --- a/parsers/parser_game.py +++ b/parsers/parser_game.py @@ -2,8 +2,9 @@ import requests from bs4 import BeautifulSoup from services.game_service import sync_match_page +from source_config import DATA_MATCH_BASE_URL, match_url -BASE_MATCH_URL = "https://wfl.rfs.ru/match/" +BASE_MATCH_URL = DATA_MATCH_BASE_URL def fetch_html(url: str) -> str: @@ -197,7 +198,7 @@ def parse_game_page(html: str) -> dict: def run_parser_game(match_external_id: str) -> None: - url = f"{BASE_MATCH_URL}{str(match_external_id).strip()}" + url = match_url(match_external_id) html = fetch_html(url) data = parse_game_page(html) diff --git a/parsers/parser_players.py b/parsers/parser_players.py index c860913..e65b021 100644 --- a/parsers/parser_players.py +++ b/parsers/parser_players.py @@ -3,10 +3,9 @@ from bs4 import BeautifulSoup from concurrent.futures import ThreadPoolExecutor from services.players_service import sync_team_roster +from parsers.parser_sources import get_parser_source, source_absolute_url -URL_TEAMS = "https://wfl.rfs.ru/tournament/1061879/teams" - AMPLUA_FULL = { "Пз.": "Полузащитник", "Вр.": "Вратарь", @@ -23,19 +22,26 @@ def fetch_html(url: str) -> str: return r.text -def get_links(html: str) -> list[dict]: +def get_links(html: str, source: dict) -> list[dict]: soup = BeautifulSoup(html, "html.parser") links: list[dict] = [] - items = soup.find("ul", class_="teams__list").find_all("li") - for i in items: - href = i.find("a", class_="teams__link").get("href") - team_external_id = href.split("team_id=")[-1].strip() + teams_list = soup.find("ul", class_="teams__list") + if not teams_list: + return links + items = teams_list.find_all("li") + for item in items: + link_el = item.find("a", class_="teams__link") + href = link_el.get("href") if link_el else "" + if not href: + continue + + team_external_id = href.split("team_id=")[-1].strip() if "team_id=" in href else "" links.append( { "team_external_id": team_external_id, - "url": "https://wfl.rfs.ru" + href, + "url": source_absolute_url(source, href), } ) @@ -92,13 +98,14 @@ def parse_team(html: str) -> dict: full_player = name_p.get_text(strip=True) if name_p else "" parts = full_player.split() + pos_short = pos_td.get_text(strip=True) if pos_td else "" players.append( { "player_id": player_id or "", "number": number_td.get_text(strip=True) if number_td else "", - "pos": pos_td.get_text(strip=True) if pos_td else "", - "amplua": AMPLUA_FULL[pos_td.get_text(strip=True) if pos_td else ""], + "pos": pos_short, + "amplua": AMPLUA_FULL.get(pos_short, pos_short), "player": full_player, "lastname": parts[0] if len(parts) >= 1 else "", "name": parts[-1] if len(parts) >= 2 else "", @@ -144,9 +151,13 @@ def parse_team(html: str) -> dict: } -def run_parser_players() -> None: - html = fetch_html(URL_TEAMS) - links = get_links(html) +def run_parser_players(source_key: str | None = None) -> None: + source = get_parser_source(source_key) + print(f"[parser_players] Источник: {source['title']}") + print(f"[parser_players] URL: {source['teams_url']}") + + html = fetch_html(source["teams_url"]) + links = get_links(html, source) with ThreadPoolExecutor(max_workers=8) as pool: futures = {pool.submit(fetch_html, item["url"]): item for item in links} @@ -169,6 +180,9 @@ def run_parser_players() -> None: except Exception as e: print(f"[parser_players] error team={item['team_external_id']}: {e}") + if not links: + print("[parser_players] Команды для парсинга игроков не найдены") + if __name__ == "__main__": run_parser_players() diff --git a/parsers/parser_schedule.py b/parsers/parser_schedule.py index c4e2db6..8cff1d2 100644 --- a/parsers/parser_schedule.py +++ b/parsers/parser_schedule.py @@ -5,6 +5,7 @@ from zoneinfo import ZoneInfo from services.schedule_service import sync_matches from repositories.team_repository import get_team_external_id_by_name, get_team_id_by_external_id +from parsers.parser_sources import get_parser_source MONTHS_RU = { @@ -24,11 +25,6 @@ MONTHS_RU = { TZ = ZoneInfo("Europe/Moscow") -URL_SCHEDULE = ( - "https://wfl.rfs.ru/tournament/1061879/calendar?round_id=1117550&type=tours" -) - -SEASON = "2025/2026" def parse_russian_date(date_str: str, year: int | None = None) -> datetime: @@ -76,7 +72,7 @@ def safe_int(value: str | None) -> int | None: return int(value) if value.isdigit() else None -def parse_schedule(html: str) -> list[dict]: +def parse_schedule(html: str, season: str, source_key: str | None = None) -> list[dict]: soup = BeautifulSoup(html, "html.parser") matches_data: list[dict] = [] @@ -151,10 +147,11 @@ def parse_schedule(html: str) -> list[dict]: "home_score": home_score, "away_score": away_score, "tour": tour, - "season": SEASON, + "season": season, "place": place, "date_raw": time_site, "score_add": score_add, + "source_key": source_key, } ) @@ -162,12 +159,18 @@ def parse_schedule(html: str) -> list[dict]: return matches_data -def run_parser_schedule() -> None: - html = fetch_html(URL_SCHEDULE) - matches_data = parse_schedule(html) +def run_parser_schedule(source_key: str | None = None) -> None: + source = get_parser_source(source_key) + print(f"[parser_schedule] Источник: {source['title']}") + print(f"[parser_schedule] URL: {source['schedule_url']}") + + html = fetch_html(source["schedule_url"]) + matches_data = parse_schedule(html, source["season"], source["key"]) if matches_data: sync_matches(matches_data) print(f"[parser_schedule] Matches synced: {len(matches_data)}") + else: + print("[parser_schedule] Матчи не найдены") if __name__ == "__main__": diff --git a/parsers/parser_sources.py b/parsers/parser_sources.py new file mode 100644 index 0000000..d7f598c --- /dev/null +++ b/parsers/parser_sources.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +from urllib.parse import urljoin + +from repositories.project_settings_repository import ( + DEFAULT_APP_SETTINGS, + DEFAULT_PARSER_SOURCES, + ensure_project_settings_tables, + get_app_setting, + get_parser_source_from_db, + list_parser_sources_from_db, +) + + +def _fallback_sources() -> list[dict]: + return [dict(item) for item in DEFAULT_PARSER_SOURCES] + + +def _fallback_sources_dict() -> dict[str, dict]: + return {item["key"]: dict(item) for item in DEFAULT_PARSER_SOURCES} + + +def _fallback_default_source_key() -> str: + return DEFAULT_APP_SETTINGS["default_parser_source_key"] + + +def _fallback_base_url() -> str: + return DEFAULT_APP_SETTINGS["rfs_base_url"].rstrip("/") + + +def _get_base_url() -> str: + try: + ensure_project_settings_tables() + return get_app_setting("rfs_base_url", _fallback_base_url()).rstrip("/") + except Exception: + return _fallback_base_url() + + +RFS_BASE_URL = _get_base_url() + + +def get_rfs_base_url() -> str: + return _get_base_url() + + +def get_default_source_key() -> str: + try: + ensure_project_settings_tables() + value = get_app_setting("default_parser_source_key", _fallback_default_source_key()) + value = (value or _fallback_default_source_key()).upper().strip() + known_keys = {source["key"] for source in list_parser_sources()} + return value if value in known_keys else _fallback_default_source_key() + except Exception: + return _fallback_default_source_key() + + +def get_parser_sources() -> dict[str, dict]: + try: + ensure_project_settings_tables() + sources = list_parser_sources_from_db(active_only=True) + if not sources: + return _fallback_sources_dict() + return {source["key"]: source for source in sources} + except Exception: + return _fallback_sources_dict() + + +def list_parser_sources() -> list[dict]: + sources = get_parser_sources() + return list(sources.values()) + + +def get_parser_source(source_key: str | None = None) -> dict: + """Возвращает настройки источника из БД. Если БД недоступна, используется fallback.""" + source_key = (source_key or get_default_source_key() or "SUPERLEAGUE").upper().strip() + try: + ensure_project_settings_tables() + source = get_parser_source_from_db(source_key) + if source: + return source + except Exception: + pass + + fallback = _fallback_sources_dict() + if source_key in fallback: + return fallback[source_key] + raise ValueError(f"Неизвестный источник парсинга: {source_key}") + + +def source_absolute_url(source: dict, path_or_url: str) -> str: + value = (path_or_url or "").strip() + if value.startswith(("http://", "https://")): + return value + base_url = (source.get("base_url") or get_rfs_base_url()).rstrip("/") + return urljoin(base_url + "/", value.lstrip("/")) + + +def source_match_url(source: dict, match_external_id: str | int) -> str: + match_base_url = (source.get("match_base_url") or f"{get_rfs_base_url()}/match/").rstrip("/") + "/" + return f"{match_base_url}{str(match_external_id).strip()}" + + +def extract_logo_filename(value: str | None) -> str: + """Возвращает только имя файла логотипа из старого полного пути или нового значения.""" + value = str(value or "").strip().strip('"').strip("'") + if not value: + return "" + + normalized = value.replace("/", "\\") + filename = normalized.split("\\")[-1].strip() + return filename + + +def ensure_logo_extension(filename: str) -> str: + filename = extract_logo_filename(filename) + if not filename: + return "" + if "." not in filename.rsplit("\\", 1)[-1]: + return f"{filename}.png" + return filename + + +def get_logo_base_path(source_key: str | None = None) -> str: + source = get_parser_source(source_key) + return str(source.get("logo_base_path") or "").rstrip("\\/") + + +def build_logo_path(source_key: str | None, filename: str | None) -> str: + """Собирает полный путь к логотипу для vMix по источнику турнира.""" + logo_file = ensure_logo_extension(filename) + if not logo_file: + return "" + + base_path = get_logo_base_path(source_key) + if not base_path: + return logo_file + + return f"{base_path}\\{logo_file}" + + +def build_logo_variant_path( + source_key: str | None, + filename: str | None, + variant: str | None = None, +) -> str: + """ + Собирает путь к варианту логотипа. + variant="white" -> Динамо_Белый / Зенит_Белый + variant="blue" -> Динамо_Синий / Зенит_Синий + Остальные команды остаются без изменения. + """ + logo_file = ensure_logo_extension(filename) + if not logo_file: + return "" + + suffix = "" + if variant == "white": + suffix = "_Белый" + elif variant == "blue": + suffix = "_Синий" + + if suffix: + lower = logo_file.lower() + if "динамо" in lower or "зенит" in lower: + if "." in logo_file: + stem, ext = logo_file.rsplit(".", 1) + ext = "." + ext + else: + stem, ext = logo_file, ".png" + + for old_suffix in ("_Белый", "_Синий"): + if stem.endswith(old_suffix): + stem = stem[: -len(old_suffix)] + logo_file = f"{stem}{suffix}{ext}" + + return build_logo_path(source_key, logo_file) + + + +def extract_photo_filename(value: str | None) -> str: + """Возвращает имя файла/относительный путь фото из старого полного пути или нового значения.""" + value = str(value or "").strip().strip('"').strip("'") + if not value: + return "" + + normalized = value.replace("/", "\\") + lower = normalized.lower() + + # Для старых полных путей пытаемся сохранить относительную часть от папки Photo. + marker = "\\photo\\" + if marker in lower: + idx = lower.rfind(marker) + return normalized[idx + len(marker):].strip("\\") + + parts = [part for part in normalized.split("\\") if part] + if len(parts) >= 2 and ":" in parts[0]: + return parts[-1].strip() + return normalized.strip("\\") + + +def ensure_photo_extension(filename: str) -> str: + filename = extract_photo_filename(filename) + if not filename: + return "" + if "." not in filename.rsplit("\\", 1)[-1]: + return f"{filename}.png" + return filename + + +def get_photo_base_path(source_key: str | None = None) -> str: + source = get_parser_source(source_key) + return str(source.get("photo_base_path") or "").rstrip("\\/") + + +def build_photo_path(source_key: str | None, filename: str | None) -> str: + """Собирает полный путь к фото игрока для vMix по источнику турнира.""" + photo_file = ensure_photo_extension(filename) + if not photo_file: + return "" + + base_path = get_photo_base_path(source_key) + if not base_path: + return photo_file + + return f"{base_path}\\{photo_file}" + + +def build_empty_photo_path(source_key: str | None = None) -> str: + """Путь к пустой картинке фото для выбранного источника.""" + return build_photo_path(source_key, "EMPTY.png") diff --git a/parsers/parser_standings.py b/parsers/parser_standings.py index ba30a17..933e2da 100644 --- a/parsers/parser_standings.py +++ b/parsers/parser_standings.py @@ -2,9 +2,8 @@ import requests from bs4 import BeautifulSoup from services.standings_service import sync_standings +from parsers.parser_sources import get_parser_source -URL_STANDINGS = "https://wfl.rfs.ru/tournament/1061879/tables" -SEASON = "2025/2026" def fetch_html(url: str) -> str: @@ -65,16 +64,22 @@ def parse_standings(html: str) -> list[dict]: return standings -def run_parser_standings() -> None: - html = fetch_html(URL_STANDINGS) +def run_parser_standings(source_key: str | None = None) -> None: + source = get_parser_source(source_key) + print(f"[parser_standings] Источник: {source['title']}") + print(f"[parser_standings] URL: {source['standings_url']}") + + html = fetch_html(source["standings_url"]) standings_rows = parse_standings(html) if standings_rows: sync_standings( - season=SEASON, + season=source["season"], standings_rows=standings_rows, ) print(f"[parser_standings] Synced rows: {len(standings_rows)}") + else: + print("[parser_standings] Строки турнирной таблицы не найдены") if __name__ == "__main__": diff --git a/parsers/parser_teams.py b/parsers/parser_teams.py index 722cc79..24cb384 100644 --- a/parsers/parser_teams.py +++ b/parsers/parser_teams.py @@ -3,9 +3,7 @@ from bs4 import BeautifulSoup from concurrent.futures import ThreadPoolExecutor from services.teams_service import sync_teams - - -TEAMS_URL = "https://wfl.rfs.ru/tournament/1061879/teams" +from parsers.parser_sources import get_parser_source, source_absolute_url def fetch_html(url: str) -> str: @@ -15,45 +13,51 @@ def fetch_html(url: str) -> str: return response.text -def get_links(html) -> list: +def get_links(html: str, source: dict) -> list[str]: soup = BeautifulSoup(html, "html.parser") - links: list[dict] = [] - items = soup.find("ul", class_="teams__list").find_all("li") - for i in items: - links.append( - "https://wfl.rfs.ru/team/" - + i.find("a", class_="teams__link").get("href").split("team_id=")[-1] - ) + links: list[str] = [] + + teams_list = soup.find("ul", class_="teams__list") + if not teams_list: + return links + + items = teams_list.find_all("li") + for item in items: + link_el = item.find("a", class_="teams__link") + href = link_el.get("href") if link_el else "" + if not href: + continue + + if "team_id=" in href: + team_external_id = href.split("team_id=")[-1].strip() + links.append(source_absolute_url(source, "/team/" + team_external_id)) + else: + links.append(source_absolute_url(source, href)) + return links -def get_url_teams() -> list[dict]: - html = fetch_html(TEAMS_URL) - links = get_links(html) +def get_url_teams(source_key: str | None = None) -> list[dict]: + source = get_parser_source(source_key) + html = fetch_html(source["teams_url"]) + links = get_links(html, source) teams_data: list[dict] = [] with ThreadPoolExecutor() as pool: - responses = [ - pool.submit( - fetch_html, - link, - ) - for link in links - ] + responses = [pool.submit(fetch_html, link) for link in links] for result in responses: try: html = result.result() team_data = parse_teams_html(html) teams_data.append(team_data) except Exception as e: - print(f"Error fetching team data: {e}") + print(f"[parser_teams] Error fetching team data: {e}") return teams_data def parse_teams_html(html: str) -> dict: soup = BeautifulSoup(html, "html.parser") - teams_data: dict = {} name = soup.find("a", class_="team-promo__team-name").text.strip() external_id = soup.find("a", class_="team-promo__logo").get("href").split("/")[-1] stat_info = soup.find("ul", class_="stats-info").find_all( @@ -65,7 +69,7 @@ def parse_teams_html(html: str) -> dict: goals = stat_info[2].text.strip() tournaments = stat_info[3].text.strip() - teams_data = { + return { "external_id": str(external_id), "name": name, "logo_url": logo_url, @@ -75,14 +79,18 @@ def parse_teams_html(html: str) -> dict: "tournaments": tournaments, } - return teams_data +def run_parser_teams(source_key: str | None = None) -> None: + source = get_parser_source(source_key) + print(f"[parser_teams] Источник: {source['title']}") + print(f"[parser_teams] URL: {source['teams_url']}") -def run_parser_teams() -> None: - teams_data = get_url_teams() + teams_data = get_url_teams(source_key) if teams_data: sync_teams(teams_data) - print(f"Teams synced: {len(teams_data)}") + print(f"[parser_teams] Teams synced: {len(teams_data)}") + else: + print("[parser_teams] Команды не найдены") if __name__ == "__main__": diff --git a/repositories/match_repository.py b/repositories/match_repository.py index 9010d2b..c6aca84 100644 --- a/repositories/match_repository.py +++ b/repositories/match_repository.py @@ -5,6 +5,24 @@ from repositories.team_repository import get_team_id_by_external_id from repositories.stadium_repository import get_or_create_stadium +def ensure_match_source_key_column() -> None: + """Добавляет источник турнира для матчей, чтобы vMix выбирал правильную папку логотипов.""" + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + ALTER TABLE matches + ADD COLUMN IF NOT EXISTS source_key VARCHAR(50); + """ + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + def upsert_match( external_id: str, @@ -20,6 +38,7 @@ def upsert_match( stadium_id: int | None = None, date_raw: str | None = None, score_add: str | None = None, + source_key: str | None = None, ) -> None: query = """ INSERT INTO matches ( @@ -36,10 +55,11 @@ INSERT INTO matches ( stadium_id, date_raw, score_add, + source_key, created_at, updated_at ) -VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW()) +VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW()) ON CONFLICT (external_id) DO UPDATE SET home_team_id = EXCLUDED.home_team_id, @@ -54,6 +74,7 @@ DO UPDATE SET stadium_id = EXCLUDED.stadium_id, date_raw = EXCLUDED.date_raw, score_add = EXCLUDED.score_add, + source_key = COALESCE(EXCLUDED.source_key, matches.source_key), updated_at = NOW(); """ @@ -76,6 +97,7 @@ DO UPDATE SET stadium_id, date_raw, score_add, + source_key, ), ) conn.commit() @@ -100,6 +122,7 @@ def upsert_match_by_team_external_ids( stadium_id: int | None = None, date_raw: str | None = None, score_add: str | None = None, + source_key: str | None = None, ) -> None: home_team_id = get_team_id_by_external_id(home_team_external_id) away_team_id = get_team_id_by_external_id(away_team_external_id) @@ -125,6 +148,7 @@ def upsert_match_by_team_external_ids( stadium_id=stadium_id, date_raw=date_raw, score_add=score_add, + source_key=source_key, ) diff --git a/repositories/match_session_repository.py b/repositories/match_session_repository.py index 2eb1312..4889613 100644 --- a/repositories/match_session_repository.py +++ b/repositories/match_session_repository.py @@ -1,6 +1,7 @@ import secrets from db import get_connection +from parsers.parser_sources import build_logo_path def create_match_session( @@ -66,7 +67,8 @@ def get_match_session_by_token(session_token: str): at.id AS away_team_id, at.name AS away_team_name, at.logo_url AS away_team_logo, - at.logo_path AS away_team_logo_path + at.logo_path AS away_team_logo_path, + m.source_key AS source_key FROM match_sessions ms JOIN matches m ON m.id = ms.match_id @@ -80,7 +82,16 @@ def get_match_session_by_token(session_token: str): try: with conn.cursor() as cur: cur.execute(query, (session_token,)) - return cur.fetchone() + row = cur.fetchone() + + if not row: + return None + + row = list(row) + source_key = row[21] if len(row) > 21 else None + row[16] = build_logo_path(source_key, row[16]) + row[20] = build_logo_path(source_key, row[20]) + return tuple(row) finally: conn.close() diff --git a/repositories/project_settings_repository.py b/repositories/project_settings_repository.py new file mode 100644 index 0000000..997f629 --- /dev/null +++ b/repositories/project_settings_repository.py @@ -0,0 +1,361 @@ +from __future__ import annotations + +from typing import Any + +from db import get_connection + + +DEFAULT_APP_SETTINGS = { + "default_parser_source_key": "SUPERLEAGUE", + "rfs_base_url": "https://wfl.rfs.ru", +} + + +DEFAULT_PARSER_SOURCES = [ + { + "key": "SUPERLEAGUE", + "title": "Суперлига 2026", + "tournament_id": "1061879", + "round_id": "1117550", + "season": "2025/2026", + "calendar_type": "tours", + "logo_base_path": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Teams Logos", + "photo_base_path": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo", + "teams_url": "https://wfl.rfs.ru/tournament/1061879/teams", + "schedule_url": "https://wfl.rfs.ru/tournament/1061879/calendar?round_id=1117550&type=tours", + "standings_url": "https://wfl.rfs.ru/tournament/1061879/tables", + "match_base_url": "https://wfl.rfs.ru/match/", + "base_url": "https://wfl.rfs.ru", + "sort_order": 10, + "is_active": True, + }, + { + "key": "RUSSIAN_CUP", + "title": "Кубок России 2026", + "tournament_id": "1064908", + "round_id": "1125159", + "season": "2026", + "calendar_type": "stages", + "logo_base_path": r"D:\Графика\ФУТБОЛ\Кубок России 2026\Teams Logos", + "photo_base_path": r"D:\Графика\ФУТБОЛ\Кубок России 2026\Photo", + "teams_url": "https://wfl.rfs.ru/tournament/1064908/teams", + "schedule_url": "https://wfl.rfs.ru/tournament/1064908/calendar?round_id=1125159&type=stages", + "standings_url": "https://wfl.rfs.ru/tournament/1064908/tables", + "match_base_url": "https://wfl.rfs.ru/match/", + "base_url": "https://wfl.rfs.ru", + "sort_order": 20, + "is_active": True, + }, +] + + +def _row_to_source(row: tuple) -> dict[str, Any]: + return { + "key": row[0] or "", + "title": row[1] or "", + "tournament_id": row[2] or "", + "round_id": row[3] or "", + "season": row[4] or "", + "calendar_type": row[5] or "tours", + "logo_base_path": row[6] or "", + "photo_base_path": row[7] or "", + "teams_url": row[8] or "", + "schedule_url": row[9] or "", + "standings_url": row[10] or "", + "match_base_url": row[11] or "", + "base_url": row[12] or "https://wfl.rfs.ru", + "sort_order": row[13] or 0, + "is_active": bool(row[14]), + } + + +def ensure_project_settings_tables() -> None: + """Создаёт таблицы настроек проекта и добавляет стандартные источники, если их ещё нет.""" + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + CREATE TABLE IF NOT EXISTS app_settings ( + key VARCHAR(100) PRIMARY KEY, + value TEXT NOT NULL DEFAULT '', + updated_at TIMESTAMP NOT NULL DEFAULT NOW() + ); + """ + ) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS parser_sources ( + key VARCHAR(50) PRIMARY KEY, + title VARCHAR(255) NOT NULL, + tournament_id VARCHAR(100), + round_id VARCHAR(100), + season VARCHAR(50), + calendar_type VARCHAR(50) NOT NULL DEFAULT 'tours', + logo_base_path TEXT, + photo_base_path TEXT, + teams_url TEXT, + schedule_url TEXT, + standings_url TEXT, + match_base_url TEXT, + base_url TEXT NOT NULL DEFAULT 'https://wfl.rfs.ru', + sort_order INTEGER NOT NULL DEFAULT 100, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() + ); + """ + ) + cur.execute("ALTER TABLE parser_sources ADD COLUMN IF NOT EXISTS photo_base_path TEXT;") + + for key, value in DEFAULT_APP_SETTINGS.items(): + cur.execute( + """ + INSERT INTO app_settings (key, value, updated_at) + VALUES (%s, %s, NOW()) + ON CONFLICT (key) DO NOTHING; + """, + (key, value), + ) + + for source in DEFAULT_PARSER_SOURCES: + cur.execute( + """ + INSERT INTO parser_sources ( + key, + title, + tournament_id, + round_id, + season, + calendar_type, + logo_base_path, + photo_base_path, + teams_url, + schedule_url, + standings_url, + match_base_url, + base_url, + sort_order, + is_active, + created_at, + updated_at + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW()) + ON CONFLICT (key) DO NOTHING; + """, + ( + source["key"], + source["title"], + source["tournament_id"], + source["round_id"], + source["season"], + source["calendar_type"], + source["logo_base_path"], + source["photo_base_path"], + source["teams_url"], + source["schedule_url"], + source["standings_url"], + source["match_base_url"], + source["base_url"], + source["sort_order"], + source["is_active"], + ), + ) + + for source in DEFAULT_PARSER_SOURCES: + cur.execute( + """ + UPDATE parser_sources + SET photo_base_path = %s, updated_at = NOW() + WHERE key = %s + AND (photo_base_path IS NULL OR TRIM(photo_base_path) = ''); + """, + (source.get("photo_base_path") or "", source["key"]), + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def get_app_settings() -> dict[str, str]: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute("SELECT key, value FROM app_settings;") + rows = cur.fetchall() + settings = dict(DEFAULT_APP_SETTINGS) + settings.update({row[0]: row[1] or "" for row in rows}) + return settings + finally: + conn.close() + + +def get_app_setting(key: str, default: str = "") -> str: + return get_app_settings().get(key, default) + + +def update_app_setting(key: str, value: str) -> None: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO app_settings (key, value, updated_at) + VALUES (%s, %s, NOW()) + ON CONFLICT (key) + DO UPDATE SET value = EXCLUDED.value, updated_at = NOW(); + """, + (key, value or ""), + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def list_parser_sources_from_db(active_only: bool = True) -> list[dict[str, Any]]: + conn = get_connection() + try: + with conn.cursor() as cur: + if active_only: + cur.execute( + """ + SELECT key, title, tournament_id, round_id, season, calendar_type, + logo_base_path, photo_base_path, teams_url, schedule_url, standings_url, + match_base_url, base_url, sort_order, is_active + FROM parser_sources + WHERE is_active = TRUE + ORDER BY sort_order ASC, title ASC; + """ + ) + else: + cur.execute( + """ + SELECT key, title, tournament_id, round_id, season, calendar_type, + logo_base_path, photo_base_path, teams_url, schedule_url, standings_url, + match_base_url, base_url, sort_order, is_active + FROM parser_sources + ORDER BY sort_order ASC, title ASC; + """ + ) + rows = cur.fetchall() + return [_row_to_source(row) for row in rows] + finally: + conn.close() + + +def get_parser_source_from_db(source_key: str) -> dict[str, Any] | None: + source_key = (source_key or "").upper().strip() + if not source_key: + return None + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT key, title, tournament_id, round_id, season, calendar_type, + logo_base_path, photo_base_path, teams_url, schedule_url, standings_url, + match_base_url, base_url, sort_order, is_active + FROM parser_sources + WHERE key = %s + LIMIT 1; + """, + (source_key,), + ) + row = cur.fetchone() + return _row_to_source(row) if row else None + finally: + conn.close() + + +def update_parser_source(source_key: str, values: dict[str, str]) -> None: + source_key = (source_key or "").upper().strip() + if not source_key: + raise ValueError("Не указан ключ источника") + + current = get_parser_source_from_db(source_key) + if not current: + current = next((item for item in DEFAULT_PARSER_SOURCES if item["key"] == source_key), None) + if not current: + current = { + "key": source_key, + "title": source_key, + "tournament_id": "", + "round_id": "", + "season": "", + "calendar_type": "tours", + "logo_base_path": "", + "photo_base_path": "", + "teams_url": "", + "schedule_url": "", + "standings_url": "", + "match_base_url": "", + "base_url": DEFAULT_APP_SETTINGS["rfs_base_url"], + "sort_order": 100, + "is_active": True, + } + + merged = {**current, **{key: value for key, value in values.items() if value is not None}} + merged["key"] = source_key + merged["calendar_type"] = merged.get("calendar_type") or "tours" + merged["base_url"] = merged.get("base_url") or DEFAULT_APP_SETTINGS["rfs_base_url"] + + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO parser_sources ( + key, title, tournament_id, round_id, season, calendar_type, + logo_base_path, photo_base_path, teams_url, schedule_url, standings_url, + match_base_url, base_url, sort_order, is_active, created_at, updated_at + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW()) + ON CONFLICT (key) + DO UPDATE SET + title = EXCLUDED.title, + tournament_id = EXCLUDED.tournament_id, + round_id = EXCLUDED.round_id, + season = EXCLUDED.season, + calendar_type = EXCLUDED.calendar_type, + logo_base_path = EXCLUDED.logo_base_path, + photo_base_path = EXCLUDED.photo_base_path, + teams_url = EXCLUDED.teams_url, + schedule_url = EXCLUDED.schedule_url, + standings_url = EXCLUDED.standings_url, + match_base_url = EXCLUDED.match_base_url, + base_url = EXCLUDED.base_url, + sort_order = EXCLUDED.sort_order, + is_active = EXCLUDED.is_active, + updated_at = NOW(); + """, + ( + merged.get("key", source_key), + merged.get("title") or source_key, + merged.get("tournament_id") or "", + merged.get("round_id") or "", + merged.get("season") or "", + merged.get("calendar_type") or "tours", + merged.get("logo_base_path") or "", + merged.get("photo_base_path") or "", + merged.get("teams_url") or "", + merged.get("schedule_url") or "", + merged.get("standings_url") or "", + merged.get("match_base_url") or "", + merged.get("base_url") or DEFAULT_APP_SETTINGS["rfs_base_url"], + int(merged.get("sort_order") or 100), + bool(merged.get("is_active", True)), + ), + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() diff --git a/repositories/team_repository.py b/repositories/team_repository.py index dfd6da8..3382291 100644 --- a/repositories/team_repository.py +++ b/repositories/team_repository.py @@ -1,4 +1,5 @@ from db import get_connection +from parsers.parser_sources import extract_logo_filename def upsert_team( @@ -153,7 +154,7 @@ def search_teams_for_admin(q: str = "") -> list[dict]: "full_name": row[2] or "", "short_name_3": row[3] or "", "city": row[4] or "", - "logo_path": row[5] or "", + "logo_path": extract_logo_filename(row[5]), "external_id": row[6] or "", } for row in rows @@ -192,7 +193,7 @@ def get_team_by_id(team_id: int) -> dict | None: "full_name": row[2] or "", "short_name_3": row[3] or "", "city": row[4] or "", - "logo_path": row[5] or "", + "logo_path": extract_logo_filename(row[5]), "external_id": row[6] or "", } finally: @@ -227,7 +228,7 @@ def update_team_admin( full_name.strip(), short_name_3.strip().upper(), city.strip(), - logo_path.strip(), + extract_logo_filename(logo_path), external_id.strip(), team_id, ), diff --git a/scheduler.py b/scheduler.py index 7650522..27adff3 100644 --- a/scheduler.py +++ b/scheduler.py @@ -12,6 +12,8 @@ from parsers.parser_standings import run_parser_standings import requests from bs4 import BeautifulSoup +from source_config import match_url + TZ = ZoneInfo("Europe/Moscow") @@ -202,7 +204,7 @@ def parse_score(value: str) -> int | None: def fetch_match_live_data(match_id: int) -> dict: - html = fetch_html(f"https://wfl.rfs.ru/match/{match_id}") + html = fetch_html(match_url(match_id)) soup = BeautifulSoup(html, "html.parser") score_box = soup.find("div", class_="score__container") diff --git a/services/env_settings_service.py b/services/env_settings_service.py new file mode 100644 index 0000000..a59014b --- /dev/null +++ b/services/env_settings_service.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any +import os +import shutil +from datetime import datetime + +from dotenv import dotenv_values, load_dotenv, set_key + +from parsers import parser_sources + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +ENV_PATH = PROJECT_ROOT / ".env" + + +def _field(key: str, label: str, value: str = "", *, field_type: str = "text", options: list[dict] | None = None, help_text: str = "", wide: bool = False) -> dict[str, Any]: + return { + "key": key, + "label": label, + "value": value or "", + "type": field_type, + "options": options or [], + "help": help_text, + "wide": wide, + } + + +def _source_field_key(source_key: str, field: str) -> str: + return f"{source_key}_{field}" + + +def get_env_file_path() -> Path: + return ENV_PATH + + +def _dotenv_values() -> dict[str, str]: + if not ENV_PATH.exists(): + return {} + raw = dotenv_values(ENV_PATH) + return {key: str(value or "") for key, value in raw.items() if key} + + +def build_env_settings_context() -> dict[str, Any]: + """Данные для формы редактирования безопасных ключей .env.""" + env_values = _dotenv_values() + sources = parser_sources.list_parser_sources() + source_options = [ + {"value": source["key"], "label": source.get("title") or source["key"]} + for source in sources + ] + + groups: list[dict[str, Any]] = [ + { + "title": "Общие настройки источников", + "subtitle": "Эти значения влияют на выбор активного турнира по умолчанию и базовый сайт RFS.", + "fields": [ + _field( + "DATA_EVENT_CODE", + "Активный источник по умолчанию", + parser_sources.get_default_source_key(), + field_type="select", + options=source_options, + help_text="Используется, если парсер запускается без выбора источника.", + ), + _field( + "RFS_BASE_URL", + "Базовый URL RFS/WFL", + parser_sources.RFS_BASE_URL, + help_text="Обычно https://wfl.rfs.ru", + wide=True, + ), + ], + } + ] + + for source in sources: + source_key = source["key"] + title = source.get("title") or source_key + groups.append( + { + "title": title, + "subtitle": f"Ключ источника: {source_key}", + "fields": [ + _field(_source_field_key(source_key, "EVENT_NAME"), "Название в интерфейсе", source.get("title", "")), + _field(_source_field_key(source_key, "SEASON"), "Сезон", source.get("season", "")), + _field(_source_field_key(source_key, "TOURNAMENT_ID"), "Tournament ID", source.get("tournament_id", "")), + _field(_source_field_key(source_key, "ROUND_ID"), "Round ID", source.get("round_id", "")), + _field( + _source_field_key(source_key, "CALENDAR_TYPE"), + "Тип календаря", + source.get("calendar_type", "tours"), + field_type="select", + options=[ + {"value": "tours", "label": "tours — туры"}, + {"value": "stages", "label": "stages — стадии"}, + ], + ), + _field(_source_field_key(source_key, "LOGO_BASE_PATH"), "Папка логотипов", source.get("logo_base_path", ""), wide=True), + _field(_source_field_key(source_key, "TEAMS_URL"), "Ссылка на команды", source.get("teams_url", ""), wide=True), + _field(_source_field_key(source_key, "SCHEDULE_URL"), "Ссылка на расписание", source.get("schedule_url", ""), wide=True), + _field(_source_field_key(source_key, "STANDINGS_URL"), "Ссылка на турнирку", source.get("standings_url", ""), wide=True), + _field(_source_field_key(source_key, "MATCH_BASE_URL"), "Базовая ссылка матча", source.get("match_base_url", ""), wide=True), + ], + } + ) + + editable_keys = [field["key"] for group in groups for field in group["fields"]] + + return { + "env_path": str(ENV_PATH), + "groups": groups, + "editable_keys": editable_keys, + "raw_values": env_values, + } + + +def get_editable_env_keys() -> set[str]: + context = build_env_settings_context() + return set(context["editable_keys"]) + + +def save_env_settings(form_values: dict[str, str]) -> list[str]: + """Сохраняет только разрешённые ключи в .env и обновляет os.environ.""" + editable_keys = get_editable_env_keys() + updates: list[str] = [] + + ENV_PATH.parent.mkdir(parents=True, exist_ok=True) + if not ENV_PATH.exists(): + ENV_PATH.write_text("", encoding="utf-8") + + backup_path = ENV_PATH.with_suffix(".env.bak") + try: + shutil.copy2(ENV_PATH, backup_path) + except Exception: + # Бэкап полезен, но не должен ломать сохранение настроек. + pass + + for key in sorted(editable_keys): + if key not in form_values: + continue + value = str(form_values.get(key) or "").strip() + set_key(str(ENV_PATH), key, value, quote_mode="always") + os.environ[key] = value + updates.append(key) + + load_dotenv(ENV_PATH, override=True) + return updates diff --git a/services/project_settings_service.py b/services/project_settings_service.py new file mode 100644 index 0000000..e298c4b --- /dev/null +++ b/services/project_settings_service.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +from typing import Any + +from parsers import parser_sources +from repositories.project_settings_repository import update_app_setting, update_parser_source + + +def _field( + key: str, + label: str, + value: str = "", + *, + field_type: str = "text", + options: list[dict] | None = None, + help_text: str = "", + wide: bool = False, +) -> dict[str, Any]: + return { + "key": key, + "label": label, + "value": value or "", + "type": field_type, + "options": options or [], + "help": help_text, + "wide": wide, + } + + +def _source_field_key(source_key: str, field: str) -> str: + return f"{source_key}_{field}" + + +def build_project_settings_context() -> dict[str, Any]: + """Данные для формы редактирования настроек проекта из БД.""" + sources = parser_sources.list_parser_sources() + source_options = [ + {"value": source["key"], "label": source.get("title") or source["key"]} + for source in sources + ] + + groups: list[dict[str, Any]] = [ + { + "title": "Общие настройки проекта", + "subtitle": "Эти значения хранятся в базе данных, а не в .env.", + "fields": [ + _field( + "DATA_EVENT_CODE", + "Активный источник по умолчанию", + parser_sources.get_default_source_key(), + field_type="select", + options=source_options, + help_text="Используется, если парсер запускается без выбора источника.", + ), + _field( + "RFS_BASE_URL", + "Базовый URL RFS/WFL", + parser_sources.get_rfs_base_url(), + help_text="Обычно https://wfl.rfs.ru", + wide=True, + ), + ], + } + ] + + for source in sources: + source_key = source["key"] + title = source.get("title") or source_key + groups.append( + { + "title": title, + "subtitle": f"Ключ источника: {source_key}", + "fields": [ + _field(_source_field_key(source_key, "EVENT_NAME"), "Название в интерфейсе", source.get("title", "")), + _field(_source_field_key(source_key, "SEASON"), "Сезон", source.get("season", "")), + _field(_source_field_key(source_key, "TOURNAMENT_ID"), "Tournament ID", source.get("tournament_id", "")), + _field(_source_field_key(source_key, "ROUND_ID"), "Round ID", source.get("round_id", "")), + _field( + _source_field_key(source_key, "CALENDAR_TYPE"), + "Тип календаря", + source.get("calendar_type", "tours"), + field_type="select", + options=[ + {"value": "tours", "label": "tours — туры"}, + {"value": "stages", "label": "stages — стадии"}, + ], + ), + _field(_source_field_key(source_key, "LOGO_BASE_PATH"), "Папка логотипов", source.get("logo_base_path", ""), wide=True), + _field(_source_field_key(source_key, "PHOTO_BASE_PATH"), "Папка фотографий", source.get("photo_base_path", ""), wide=True), + _field(_source_field_key(source_key, "TEAMS_URL"), "Ссылка на команды", source.get("teams_url", ""), wide=True), + _field(_source_field_key(source_key, "SCHEDULE_URL"), "Ссылка на расписание", source.get("schedule_url", ""), wide=True), + _field(_source_field_key(source_key, "STANDINGS_URL"), "Ссылка на турнирку", source.get("standings_url", ""), wide=True), + _field(_source_field_key(source_key, "MATCH_BASE_URL"), "Базовая ссылка матча", source.get("match_base_url", ""), wide=True), + ], + } + ) + + editable_keys = [field["key"] for group in groups for field in group["fields"]] + return { + "storage_title": "База данных", + "groups": groups, + "editable_keys": editable_keys, + } + + +def save_project_settings(form_values: dict[str, str]) -> list[str]: + """Сохраняет настройки проекта в БД.""" + updated: list[str] = [] + + default_source_key = str(form_values.get("DATA_EVENT_CODE") or "SUPERLEAGUE").upper().strip() + rfs_base_url = str(form_values.get("RFS_BASE_URL") or "https://wfl.rfs.ru").strip().rstrip("/") + + update_app_setting("default_parser_source_key", default_source_key) + update_app_setting("rfs_base_url", rfs_base_url) + updated.extend(["DATA_EVENT_CODE", "RFS_BASE_URL"]) + + sources = parser_sources.list_parser_sources() + field_map = { + "EVENT_NAME": "title", + "SEASON": "season", + "TOURNAMENT_ID": "tournament_id", + "ROUND_ID": "round_id", + "CALENDAR_TYPE": "calendar_type", + "LOGO_BASE_PATH": "logo_base_path", + "PHOTO_BASE_PATH": "photo_base_path", + "TEAMS_URL": "teams_url", + "SCHEDULE_URL": "schedule_url", + "STANDINGS_URL": "standings_url", + "MATCH_BASE_URL": "match_base_url", + } + + for source in sources: + source_key = source["key"] + values: dict[str, str] = {"base_url": rfs_base_url} + for form_field, db_field in field_map.items(): + full_key = _source_field_key(source_key, form_field) + if full_key in form_values: + values[db_field] = str(form_values.get(full_key) or "").strip() + updated.append(full_key) + update_parser_source(source_key, values) + + return updated diff --git a/services/schedule_service.py b/services/schedule_service.py index f8c6fa9..2941486 100644 --- a/services/schedule_service.py +++ b/services/schedule_service.py @@ -16,4 +16,5 @@ def sync_matches(matches_data: list[dict]) -> None: place=match.get("place"), date_raw=match.get("date_raw"), score_add=match.get("score_add"), - ) \ No newline at end of file + source_key=match.get("source_key"), + ) diff --git a/services/vmix_json_service.py b/services/vmix_json_service.py index 8f58b56..94c7504 100644 --- a/services/vmix_json_service.py +++ b/services/vmix_json_service.py @@ -1,27 +1,44 @@ # services/vmix_json_service.py from db import get_connection from repositories.match_lineup_repository import get_match_lineup_for_vmix +from parsers.parser_sources import build_empty_photo_path, build_logo_path, build_logo_variant_path, build_photo_path -PHOTO_BASE_PATH = r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo" -EMPTY_PHOTO_PATH = PHOTO_BASE_PATH + r"\EMPTY.png" +DEFAULT_PHOTO_BASE_PATH = r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo" +EMPTY_PHOTO_PATH = DEFAULT_PHOTO_BASE_PATH + r"\EMPTY.png" -def build_generated_player_photo_path(team_name: str, last_name: str, first_name: str) -> str: - return ( - PHOTO_BASE_PATH - + "\\" - + str(team_name or "") +def _session_source_key(session_row) -> str | None: + try: + value = session_row[21] + except Exception: + value = None + return str(value).strip() if value else None + + +def build_generated_player_photo_path( + team_name: str, + last_name: str, + first_name: str, + source_key: str | None = None, +) -> str: + relative_file = ( + str(team_name or "").strip() + "\\" + (str(last_name or "") + " " + str(first_name or "")).strip() + ".png" ) + return build_photo_path(source_key, relative_file) -def resolve_player_photo(photo_enabled: bool, generated_photo: str) -> str: +def resolve_player_photo( + photo_enabled: bool, + generated_photo: str, + source_key: str | None = None, +) -> str: if photo_enabled: return generated_photo - return EMPTY_PHOTO_PATH + return build_empty_photo_path(source_key) or EMPTY_PHOTO_PATH def _normalize_text(value) -> str: @@ -208,6 +225,7 @@ def resolve_player_photo_for_json( player: dict, generated_photo: str, fallback_team_id=None, + source_key: str | None = None, ) -> str: state = _find_player_photo_state( player_id=player.get("player_id") or player.get("id"), @@ -220,16 +238,24 @@ def resolve_player_photo_for_json( ) if state is not None: - _photo, photo_enabled = state - return resolve_player_photo(photo_enabled=photo_enabled, generated_photo=generated_photo) + photo_value, photo_enabled = state + resolved_photo = build_photo_path(source_key, photo_value) if photo_value else generated_photo + return resolve_player_photo( + photo_enabled=photo_enabled, + generated_photo=resolved_photo, + source_key=source_key, + ) + photo_value = player.get("photo") or "" + resolved_photo = build_photo_path(source_key, photo_value) if photo_value else generated_photo return resolve_player_photo( photo_enabled=player.get("photo_enabled"), - generated_photo=generated_photo, + generated_photo=resolved_photo, + source_key=source_key, ) -def build_lineup_json(match_id, home_team_id, away_team_id, name, team_a_name, team_b_name): +def build_lineup_json(match_id, home_team_id, away_team_id, name, team_a_name, team_b_name, source_key=None): lineups = get_match_lineup_for_vmix( match_id=match_id, home_team_id=home_team_id, @@ -274,8 +300,10 @@ def build_lineup_json(match_id, home_team_id, away_team_id, name, team_a_name, t team_name=team_a_name if "home" in name else team_b_name, last_name=p.get("last_name", ""), first_name=p.get("first_name", ""), + source_key=source_key, ), fallback_team_id=fallback_team_id, + source_key=source_key, ), } ) @@ -305,43 +333,16 @@ def get_vmix_match_info_by_token(session_token: str): m.tour, ht.logo_path AS home_logo, - REPLACE(at.logo_path, 'HOME', 'AWAY') AS away_logo, + at.logo_path AS away_logo, ref1.referee_name AS referee1, ref2.referee_name AS referee2, ref3.referee_name AS referee3, ref4.referee_name AS referee4, - CASE - WHEN ht.name ILIKE '%%динамо%%' - THEN REPLACE(REPLACE(ht.logo_path, 'HOME\\', ''), 'Динамо', 'Динамо_Белый') - WHEN ht.name ILIKE '%%зенит%%' - THEN REPLACE(REPLACE(ht.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Белый') - ELSE REPLACE(ht.logo_path, 'HOME\\', '') - END AS home_logo1, - - CASE - WHEN at.name ILIKE '%%динамо%%' - THEN REPLACE(REPLACE(at.logo_path, 'HOME\\', ''), 'Динамо', 'Динамо_Белый') - WHEN at.name ILIKE '%%зенит%%' - THEN REPLACE(REPLACE(at.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Белый') - ELSE REPLACE(at.logo_path, 'HOME\\', '') - END AS away_logo1, - - CASE - WHEN ht.name ILIKE '%%динамо%%' - THEN REPLACE(REPLACE(ht.logo_path, 'HOME\\', ''), 'Динамо', 'Динамо_Синий') - WHEN ht.name ILIKE '%%зенит%%' - THEN REPLACE(REPLACE(ht.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Синий') - ELSE REPLACE(ht.logo_path, 'HOME\\', '') - END AS home_logo2, - - CASE - WHEN at.name ILIKE '%%динамо%%' - THEN REPLACE(REPLACE(at.logo_path, 'HOME\\', ''), 'Динамо', 'Динамо_Синий') - WHEN at.name ILIKE '%%зенит%%' - THEN REPLACE(REPLACE(at.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Синий') - ELSE REPLACE(at.logo_path, 'HOME\\', '') - END AS away_logo2, + ht.logo_path AS home_logo1, + at.logo_path AS away_logo1, + ht.logo_path AS home_logo2, + at.logo_path AS away_logo2, ht.city AS home_city, at.city AS away_city, @@ -350,7 +351,8 @@ def get_vmix_match_info_by_token(session_token: str): c1.amplua AS coach_amplua1, TRIM(COALESCE(c2.name, '') || ' ' || COALESCE(c2.lastname, '')) AS coach_name2, - c2.amplua AS coach_amplua2 + c2.amplua AS coach_amplua2, + m.source_key AS source_key FROM match_sessions ms JOIN matches m ON m.id = ms.match_id @@ -378,23 +380,33 @@ def get_vmix_match_info_by_token(session_token: str): try: with conn.cursor() as cur: cur.execute(query, (session_token,)) - return cur.fetchone() + row = cur.fetchone() + + if not row: + return None + + row = list(row) + source_key = row[30] if len(row) > 30 else None + row[14] = build_logo_path(source_key, row[14]) + row[15] = build_logo_path(source_key, row[15]) + row[20] = build_logo_variant_path(source_key, row[20], "white") + row[21] = build_logo_variant_path(source_key, row[21], "white") + row[22] = build_logo_variant_path(source_key, row[22], "blue") + row[23] = build_logo_variant_path(source_key, row[23], "blue") + return tuple(row) finally: conn.close() -def get_vmix_standings(session_token: str): +def get_vmix_standings(session_row): + source_key = _session_source_key(session_row) + season = session_row[11] if len(session_row) > 11 else None + query = """ SELECT s.position, - t.full_name, - CASE - WHEN t.full_name ILIKE '%%динамо%%' - THEN REPLACE(REPLACE(t.logo_path, 'HOME\\', ''), 'Динамо', 'Динамо_Белый') - WHEN t.full_name ILIKE '%%зенит%%' - THEN REPLACE(REPLACE(t.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Белый') - ELSE REPLACE(t.logo_path, 'HOME\\', '') - END AS logo, + COALESCE(t.full_name, t.name) AS team_name, + t.logo_path AS logo, s.played, s.wins, s.losses, @@ -404,35 +416,36 @@ def get_vmix_standings(session_token: str): s.team_id FROM standings s LEFT JOIN teams t ON s.team_id = t.id + WHERE (%s IS NULL OR s.season = %s) ORDER BY s.position """ conn = get_connection() try: with conn.cursor() as cur: - cur.execute(query, (session_token,)) - return cur.fetchall() + cur.execute(query, (season, season)) + rows = cur.fetchall() + + result = [] + for row in rows: + row = list(row) + row[2] = build_logo_variant_path(source_key, row[2], "white") + result.append(tuple(row)) + return result finally: conn.close() -def get_vmix_schedule(session_token: str): +def get_vmix_schedule(session_row): + source_key = _session_source_key(session_row) + tour = session_row[10] if len(session_row) > 10 else None + season = session_row[11] if len(session_row) > 11 else None + source_filter = source_key or "SUPERLEAGUE" + query = """ SELECT - CASE - WHEN t1.full_name ILIKE '%%динамо%%' - THEN REPLACE(REPLACE(t1.logo_path, 'HOME\\', ''), 'Динамо', 'Динамо_Белый') - WHEN t1.full_name ILIKE '%%зенит%%' - THEN REPLACE(REPLACE(t1.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Белый') - ELSE REPLACE(t1.logo_path, 'HOME\\', '') - END AS logo1, - CASE - WHEN t2.full_name ILIKE '%%динамо%%' - THEN REPLACE(REPLACE(t2.logo_path, 'HOME\\', ''), 'Динамо', 'Динамо_Белый') - WHEN t2.full_name ILIKE '%%зенит%%' - THEN REPLACE(REPLACE(t2.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Белый') - ELSE REPLACE(t2.logo_path, 'HOME\\', '') - END AS logo2, + t1.logo_path AS logo1, + t2.logo_path AS logo2, m.home_score, m.away_score, m.match_date, @@ -443,14 +456,24 @@ def get_vmix_schedule(session_token: str): LEFT JOIN teams t1 ON m.home_team_id = t1.id LEFT JOIN teams t2 ON m.away_team_id = t2.id WHERE m.tour = %s + AND (%s IS NULL OR m.season = %s) + AND COALESCE(m.source_key, %s) = %s ORDER BY m.match_date, m.id """ conn = get_connection() try: with conn.cursor() as cur: - cur.execute(query, (session_token[10],)) - return cur.fetchall() + cur.execute(query, (tour, season, season, source_filter, source_filter)) + rows = cur.fetchall() + + result = [] + for row in rows: + row = list(row) + row[0] = build_logo_variant_path(source_key, row[0], "white") + row[1] = build_logo_variant_path(source_key, row[1], "white") + result.append(tuple(row)) + return result finally: conn.close() diff --git a/source_config.py b/source_config.py new file mode 100644 index 0000000..aca9fab --- /dev/null +++ b/source_config.py @@ -0,0 +1,32 @@ +from parsers.parser_sources import ( + RFS_BASE_URL, + get_default_source_key, + get_parser_source, + source_absolute_url, + source_match_url, +) + + +_default_source = get_parser_source() + +# Старые константы оставлены для совместимости с уже существующим кодом. +DATA_EVENT_CODE = get_default_source_key() +DATA_EVENT_NAME = _default_source["title"] +DATA_TOURNAMENT_ID = _default_source["tournament_id"] +DATA_ROUND_ID = _default_source["round_id"] +DATA_SEASON = _default_source["season"] +DATA_TEAMS_URL = _default_source["teams_url"] +DATA_SCHEDULE_URL = _default_source["schedule_url"] +DATA_STANDINGS_URL = _default_source["standings_url"] +DATA_MATCH_BASE_URL = _default_source["match_base_url"] +DATA_LOGO_BASE_PATH = _default_source.get("logo_base_path", "") +DATA_PHOTO_BASE_PATH = _default_source.get("photo_base_path", "") + + +def absolute_url(path_or_url: str) -> str: + """Возвращает абсолютную ссылку для href с сайта WFL.""" + return source_absolute_url(_default_source, path_or_url) + + +def match_url(match_external_id: str | int) -> str: + return source_match_url(_default_source, match_external_id) diff --git a/sql/003_matches_source_key.sql b/sql/003_matches_source_key.sql new file mode 100644 index 0000000..8799dd0 --- /dev/null +++ b/sql/003_matches_source_key.sql @@ -0,0 +1,4 @@ +ALTER TABLE matches +ADD COLUMN IF NOT EXISTS source_key VARCHAR(50); + +CREATE INDEX IF NOT EXISTS idx_matches_source_key ON matches(source_key); diff --git a/sql/004_project_settings.sql b/sql/004_project_settings.sql new file mode 100644 index 0000000..27b4e2e --- /dev/null +++ b/sql/004_project_settings.sql @@ -0,0 +1,79 @@ +-- Настройки проекта и источники парсинга теперь хранятся в БД, а не в .env. + +CREATE TABLE IF NOT EXISTS app_settings ( + key VARCHAR(100) PRIMARY KEY, + value TEXT NOT NULL DEFAULT '', + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS parser_sources ( + key VARCHAR(50) PRIMARY KEY, + title VARCHAR(255) NOT NULL, + tournament_id VARCHAR(100), + round_id VARCHAR(100), + season VARCHAR(50), + calendar_type VARCHAR(50) NOT NULL DEFAULT 'tours', + logo_base_path TEXT, + photo_base_path TEXT, + teams_url TEXT, + schedule_url TEXT, + standings_url TEXT, + match_base_url TEXT, + base_url TEXT NOT NULL DEFAULT 'https://wfl.rfs.ru', + sort_order INTEGER NOT NULL DEFAULT 100, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +INSERT INTO app_settings (key, value, updated_at) +VALUES + ('default_parser_source_key', 'SUPERLEAGUE', NOW()), + ('rfs_base_url', 'https://wfl.rfs.ru', NOW()) +ON CONFLICT (key) DO NOTHING; + +INSERT INTO parser_sources ( + key, title, tournament_id, round_id, season, calendar_type, logo_base_path, photo_base_path, + teams_url, schedule_url, standings_url, match_base_url, base_url, + sort_order, is_active, created_at, updated_at +) +VALUES + ( + 'SUPERLEAGUE', + 'Суперлига 2026', + '1061879', + '1117550', + '2025/2026', + 'tours', + 'D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Teams Logos', + 'D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo', + 'https://wfl.rfs.ru/tournament/1061879/teams', + 'https://wfl.rfs.ru/tournament/1061879/calendar?round_id=1117550&type=tours', + 'https://wfl.rfs.ru/tournament/1061879/tables', + 'https://wfl.rfs.ru/match/', + 'https://wfl.rfs.ru', + 10, + TRUE, + NOW(), + NOW() + ), + ( + 'RUSSIAN_CUP', + 'Кубок России 2026', + '1064908', + '1125159', + '2026', + 'stages', + 'D:\Графика\ФУТБОЛ\Кубок России 2026\Teams Logos', + 'D:\Графика\ФУТБОЛ\Кубок России 2026\Photo', + 'https://wfl.rfs.ru/tournament/1064908/teams', + 'https://wfl.rfs.ru/tournament/1064908/calendar?round_id=1125159&type=stages', + 'https://wfl.rfs.ru/tournament/1064908/tables', + 'https://wfl.rfs.ru/match/', + 'https://wfl.rfs.ru', + 20, + TRUE, + NOW(), + NOW() + ) +ON CONFLICT (key) DO NOTHING; diff --git a/sql/005_parser_sources_photo_base_path.sql b/sql/005_parser_sources_photo_base_path.sql new file mode 100644 index 0000000..20588ac --- /dev/null +++ b/sql/005_parser_sources_photo_base_path.sql @@ -0,0 +1,14 @@ +-- Папка фотографий для каждого источника турнира. + +ALTER TABLE parser_sources +ADD COLUMN IF NOT EXISTS photo_base_path TEXT; + +UPDATE parser_sources +SET photo_base_path = 'D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo' +WHERE key = 'SUPERLEAGUE' + AND (photo_base_path IS NULL OR TRIM(photo_base_path) = ''); + +UPDATE parser_sources +SET photo_base_path = 'D:\Графика\ФУТБОЛ\Кубок России 2026\Photo' +WHERE key = 'RUSSIAN_CUP' + AND (photo_base_path IS NULL OR TRIM(photo_base_path) = ''); diff --git a/static/script.js b/static/script.js index bab42e6..03891dd 100644 --- a/static/script.js +++ b/static/script.js @@ -1260,14 +1260,28 @@ function closeGoalEditor() { function getVmixTeamLogo(teamName) { + const matchData = window.MATCH_DATA || {}; + const teamNames = matchData.teamNames || {}; + const vmixTeamLogos = matchData.vmixTeamLogos || {}; + const normalizedTeamName = String(teamName || "").trim(); + + if (normalizedTeamName && normalizedTeamName === String(teamNames.home || "").trim()) { + return vmixTeamLogos.home || ""; + } + + if (normalizedTeamName && normalizedTeamName === String(teamNames.away || "").trim()) { + return vmixTeamLogos.away || ""; + } + + // Fallback для старых страниц без MATCH_DATA. const basePath = "D:\\Графика\\ФУТБОЛ\\Женская Суперлига 2026\\Teams Logos"; - let clean = String(teamName || "").replaceAll("«", "").replaceAll("»", "").trim(); + let clean = normalizedTeamName.replaceAll("«", "").replaceAll("»", "").trim(); if (clean === "Зенит" || clean === "Динамо") { clean += "_Синий"; } - return `${basePath}\\${clean}.png`; + return clean ? `${basePath}\\${clean}.png` : ""; } function getScoreSum() { @@ -4063,4 +4077,4 @@ function closeClearMatchModal() { function confirmClearMatch() { closeClearMatchModal(); clearMatchEvents(); -} \ No newline at end of file +} diff --git a/templates/admin_db_index.html b/templates/admin_db_index.html index ecc55f0..b354f9a 100644 --- a/templates/admin_db_index.html +++ b/templates/admin_db_index.html @@ -107,6 +107,82 @@ margin: 0; } + + + .parser-import-card { + width: 100%; + padding: 18px; + border: 1px solid rgba(0, 255, 136, 0.18); + border-radius: 16px; + background: rgba(255, 255, 255, 0.025); + } + + .parser-import-title { + font-size: 18px; + font-weight: 700; + margin-bottom: 6px; + } + + .parser-import-subtitle { + color: var(--muted); + font-size: 13px; + line-height: 1.45; + margin-bottom: 16px; + } + + .parser-import-grid { + display: grid; + grid-template-columns: minmax(260px, 360px) minmax(0, 1fr); + gap: 16px; + align-items: start; + } + + .parser-checkboxes { + display: grid; + grid-template-columns: repeat(4, minmax(120px, 1fr)); + gap: 10px; + } + + .parser-checkbox { + display: flex; + align-items: center; + gap: 8px; + min-height: 44px; + padding: 0 12px; + border-radius: 12px; + border: 1px solid var(--border); + background: #0f141d; + color: var(--text); + font-size: 14px; + font-weight: 600; + cursor: pointer; + } + + .parser-checkbox input { + accent-color: var(--accent); + } + + .parser-submit-row { + margin-top: 16px; + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + } + + .parser-mode-note { + color: var(--muted); + font-size: 12px; + line-height: 1.45; + } + + @media (max-width: 900px) { + .parser-import-grid, + .parser-checkboxes { + grid-template-columns: 1fr; + } + } + .log-block { margin-top: 20px; border: 1px solid var(--border); @@ -151,7 +227,8 @@ } .account-modal, - .update-modal { + .update-modal, + .settings-modal { position: fixed; inset: 0; display: none; @@ -163,12 +240,14 @@ } .account-modal.active, - .update-modal.active { + .update-modal.active, + .settings-modal.active { display: flex; } .account-modal-card, - .update-modal-card { + .update-modal-card, + .settings-modal-card { width: min(100%, 460px); background: rgba(12, 17, 26, 0.98); border: 1px solid rgba(0, 255, 136, 0.28); @@ -177,21 +256,24 @@ padding: 24px; } - .update-modal-card { + .update-modal-card, + .settings-modal-card { width: min(100%, 760px); max-height: 92vh; overflow-y: auto; } .account-modal-title, - .update-modal-title { + .update-modal-title, + .settings-modal-title { font-size: 22px; font-weight: 700; margin-bottom: 8px; } .account-modal-subtitle, - .update-modal-subtitle { + .update-modal-subtitle, + .settings-modal-subtitle { color: var(--muted); margin-bottom: 18px; line-height: 1.5; @@ -202,6 +284,46 @@ gap: 14px; } + .env-groups { + display: grid; + gap: 16px; + } + + .env-group { + border: 1px solid var(--border); + border-radius: 14px; + padding: 14px; + background: rgba(255, 255, 255, 0.025); + } + + .env-group-title { + font-size: 16px; + font-weight: 700; + margin-bottom: 4px; + } + + .env-group-subtitle { + color: var(--muted); + font-size: 12px; + margin-bottom: 12px; + } + + .env-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + } + + .env-field-wide { + grid-column: 1 / -1; + } + + @media (max-width: 720px) { + .env-grid { + grid-template-columns: 1fr; + } + } + .field-label { display: block; font-size: 13px; @@ -407,24 +529,66 @@
-
- - -
+
+
Импорт данных
+
+ Выберите источник турнира и отметьте, какие данные нужно заграбить. Существующие команды, игроки и матчи будут обновлены через UPSERT, без полной очистки таблиц. +
- - - -
+
+
+ + {% set sources = parser_sources.values() if parser_sources is mapping else parser_sources|default([]) %} + +
Список источников хранится в базе данных и редактируется в настройках проекта.
+
Путь к логотипам:
+
Путь к фотографиям:
+
-
- - +
+ +
+ + + + +
+
+
+ +
+ +
Безопасный режим: добавляет новые записи и обновляет существующие. Турнирка по-прежнему пересобирается только за выбранный сезон.
+
+
@@ -458,6 +622,21 @@ {% endif %} + {% if settings_output %} +
+
Сохранение настроек проекта
+
+ Статус: + {% if settings_success %} + успешно + {% else %} + ошибка + {% endif %} +
+
{{ settings_output }}
+
+ {% endif %} + {% if update_output %}
Добавление обновления
@@ -510,6 +689,58 @@
+ + +