обновление для Кубка России

переделаны все парсеры на ссылки из базы
This commit is contained in:
2026-07-02 12:46:29 +03:00
parent b0fe3ad5a4
commit 57907fd86b
29 changed files with 1838 additions and 240 deletions

View File

@@ -13,8 +13,8 @@ import requests
import websockets import websockets
VMIX_API = "http://127.0.0.1:8088/api" VMIX_API = "http://127.0.0.1:8088/api"
WS_BASE = "wss://wfl.tvstart.ru/ws/vmix-client" # WS_BASE = "wss://wfl.tvstart.ru/ws/vmix-client"
# WS_BASE = "ws://127.0.0.1:8000/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]}" CLIENT_ID = f"vmix-{socket.gethostname().lower()}-{uuid.uuid4().hex[:6]}"
POLL_INTERVAL = 3 POLL_INTERVAL = 3

196
app.py
View File

@@ -33,6 +33,10 @@ from parsers.parser_game import run_parser_game
from parsers.parser_players import run_parser_players from parsers.parser_players import run_parser_players
from parsers.parser_schedule import run_parser_schedule from parsers.parser_schedule import run_parser_schedule
from parsers.parser_standings import run_parser_standings 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 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 repositories.match_view_repository import get_match_lineups_grouped
from vmix.vmix_service import build_vmix_project_bytes, build_vmix_filename from vmix.vmix_service import build_vmix_project_bytes, build_vmix_filename
from repositories.match_referee_repository import get_match_referees 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.standings_repository import get_standings_by_match_id
from repositories.match_coach_repository import get_match_coaches_grouped from repositories.match_coach_repository import get_match_coaches_grouped
from repositories.player_repository import ( from repositories.player_repository import (
@@ -186,7 +190,9 @@ def start_scheduler():
import threading import threading
try: try:
ensure_project_settings_tables()
ensure_player_photo_enabled_column() ensure_player_photo_enabled_column()
ensure_match_source_key_column()
except Exception: except Exception:
traceback.print_exc() traceback.print_exc()
@@ -700,10 +706,13 @@ def download_vmix_project(request: Request, session_token: str):
operator_login = current_user.get("username") or None operator_login = current_user.get("username") or None
try: try:
source_key = session_row[21] if len(session_row) > 21 else None
vmix_bytes = build_vmix_project_bytes( vmix_bytes = build_vmix_project_bytes(
session_token=session_token, session_token=session_token,
match_id=session_row[1], match_id=session_row[1],
operator_login=operator_login, operator_login=operator_login,
source_key=source_key,
) )
filename = build_vmix_filename(session_row) filename = build_vmix_filename(session_row)
@@ -852,16 +861,6 @@ def close_session(session_token: str):
return RedirectResponse(url="/admin/matches", status_code=303) 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) @app.get("/admin/db/players", response_class=HTMLResponse)
def admin_db_players( 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): def get_roster_data(session_token: str, name: str, count_player: int):
session_row = get_match_session_by_token(session_token) session_row = get_match_session_by_token(session_token)
if not session_row: 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("»", "") home_team_name = session_row[14].replace("«", "").replace("»", "")
away_team_id = session_row[17] away_team_id = session_row[17]
away_team_name = session_row[18].replace("«", "").replace("»", "") away_team_name = session_row[18].replace("«", "").replace("»", "")
source_key = session_row[21] if len(session_row) > 21 else None
# print(session_row) # print(session_row)
data = build_lineup_json( 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 = 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] data["players"] = players[:count_player]
return data 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 = [] result = []
for p in rows: 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 "", "first_name": p[4] if len(p) > 4 else "",
"number": number, "number": number,
"position": position, "position": position,
"photo": p[5] if len(p) > 5 else "",
"photo_enabled": p[6] if len(p) > 6 else False, "photo_enabled": p[6] if len(p) > 6 else False,
}, },
generated_photo=build_generated_player_photo_path( generated_photo=build_generated_player_photo_path(
team_name=team_name, team_name=team_name,
last_name=lastname, last_name=lastname,
first_name=p[4] if len(p) > 4 else "", first_name=p[4] if len(p) > 4 else "",
source_key=source_key,
), ),
fallback_team_id=team_id, 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 return result
@@ -1743,8 +1764,9 @@ def vmix_home_formations(session_token: str):
home_team_id = session_row[13] home_team_id = session_row[13]
home_team = session_row[14].replace("«", "").replace("»", "") 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) 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") @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_id = session_row[17]
away_team = session_row[18].replace("«", "").replace("»", "") 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) 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") @app.get("/vmix/session/{session_token}/scoreboard")
@@ -2263,17 +2286,49 @@ def render_admin_db_index(
parser_success: bool | None = None, parser_success: bool | None = None,
update_output: str | None = None, update_output: str | None = None,
update_success: bool | 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( return templates.TemplateResponse(
name="admin_db_index.html", name="admin_db_index.html",
request=request, request=request,
context={ context={
"current_user": getattr(request.state, "current_user", None), "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_output": parser_output,
"parser_name": parser_name, "parser_name": parser_name,
"parser_success": parser_success, "parser_success": parser_success,
"update_output": update_output, "update_output": update_output,
"update_success": update_success, "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) @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) current_user = getattr(request.state, "current_user", None)
if not current_user or current_user.get("role") != "admin": if not current_user or current_user.get("role") != "admin":
return RedirectResponse(url="/login", status_code=303) return RedirectResponse(url="/login", status_code=303)
parser_map = { parser_map = {
"teams": ("Команды", run_parser_teams),
"players": ("Игроки", run_parser_players), "players": ("Игроки", run_parser_players),
"schedule": ("Расписание", run_parser_schedule), "schedule": ("Расписание", run_parser_schedule),
"standings": ("Турнирка", run_parser_standings), "standings": ("Турнирка", run_parser_standings),
} }
parser_meta = parser_map.get(parser_name) selected_parser_names = list(parser_names or [])
if not parser_meta: 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( return render_admin_db_index(
request, request,
parser_output="Неизвестный парсер.", parser_output="Выберите хотя бы один тип данных для парсинга.",
parser_name=parser_name, parser_name="Импорт данных",
parser_success=False, 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() buffer = io.StringIO()
success = True success = True
with contextlib.redirect_stdout(buffer), contextlib.redirect_stderr(buffer): with contextlib.redirect_stdout(buffer), contextlib.redirect_stderr(buffer):
print( print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Источник: {source['title']}")
f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Запуск парсера: {title}" print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Запуск: {', '.join(titles)}")
)
try: for selected_name in selected_parser_names:
parser_func() item_title, parser_func = parser_map[selected_name]
print( print()
f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Парсер завершён успешно" print(f"===== {item_title} =====")
) try:
except Exception: parser_func(source_key=source["key"])
success = False print(
print( f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {item_title}: успешно"
f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Ошибка при выполнении парсера" )
) except Exception:
print(traceback.format_exc()) 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 "Парсер не вернул сообщений." output = buffer.getvalue().strip() or "Парсер не вернул сообщений."
return render_admin_db_index( 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) @app.post("/admin/db/add-update", response_class=HTMLResponse)
def admin_db_add_update( def admin_db_add_update(
request: Request, request: Request,

View File

@@ -2,8 +2,9 @@ import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from services.game_service import sync_match_page 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: 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: 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) html = fetch_html(url)
data = parse_game_page(html) data = parse_game_page(html)

View File

@@ -3,10 +3,9 @@ from bs4 import BeautifulSoup
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from services.players_service import sync_team_roster 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 = { AMPLUA_FULL = {
"Пз.": "Полузащитник", "Пз.": "Полузащитник",
"Вр.": "Вратарь", "Вр.": "Вратарь",
@@ -23,19 +22,26 @@ def fetch_html(url: str) -> str:
return r.text return r.text
def get_links(html: str) -> list[dict]: def get_links(html: str, source: dict) -> list[dict]:
soup = BeautifulSoup(html, "html.parser") soup = BeautifulSoup(html, "html.parser")
links: list[dict] = [] links: list[dict] = []
items = soup.find("ul", class_="teams__list").find_all("li") teams_list = soup.find("ul", class_="teams__list")
for i in items: if not teams_list:
href = i.find("a", class_="teams__link").get("href") return links
team_external_id = href.split("team_id=")[-1].strip()
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( links.append(
{ {
"team_external_id": team_external_id, "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 "" full_player = name_p.get_text(strip=True) if name_p else ""
parts = full_player.split() parts = full_player.split()
pos_short = pos_td.get_text(strip=True) if pos_td else ""
players.append( players.append(
{ {
"player_id": player_id or "", "player_id": player_id or "",
"number": number_td.get_text(strip=True) if number_td else "", "number": number_td.get_text(strip=True) if number_td else "",
"pos": pos_td.get_text(strip=True) if pos_td else "", "pos": pos_short,
"amplua": AMPLUA_FULL[pos_td.get_text(strip=True) if pos_td else ""], "amplua": AMPLUA_FULL.get(pos_short, pos_short),
"player": full_player, "player": full_player,
"lastname": parts[0] if len(parts) >= 1 else "", "lastname": parts[0] if len(parts) >= 1 else "",
"name": parts[-1] if len(parts) >= 2 else "", "name": parts[-1] if len(parts) >= 2 else "",
@@ -144,9 +151,13 @@ def parse_team(html: str) -> dict:
} }
def run_parser_players() -> None: def run_parser_players(source_key: str | None = None) -> None:
html = fetch_html(URL_TEAMS) source = get_parser_source(source_key)
links = get_links(html) 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: with ThreadPoolExecutor(max_workers=8) as pool:
futures = {pool.submit(fetch_html, item["url"]): item for item in links} 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: except Exception as e:
print(f"[parser_players] error team={item['team_external_id']}: {e}") print(f"[parser_players] error team={item['team_external_id']}: {e}")
if not links:
print("[parser_players] Команды для парсинга игроков не найдены")
if __name__ == "__main__": if __name__ == "__main__":
run_parser_players() run_parser_players()

View File

@@ -5,6 +5,7 @@ from zoneinfo import ZoneInfo
from services.schedule_service import sync_matches 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 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 = { MONTHS_RU = {
@@ -24,11 +25,6 @@ MONTHS_RU = {
TZ = ZoneInfo("Europe/Moscow") 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: 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 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") soup = BeautifulSoup(html, "html.parser")
matches_data: list[dict] = [] matches_data: list[dict] = []
@@ -151,10 +147,11 @@ def parse_schedule(html: str) -> list[dict]:
"home_score": home_score, "home_score": home_score,
"away_score": away_score, "away_score": away_score,
"tour": tour, "tour": tour,
"season": SEASON, "season": season,
"place": place, "place": place,
"date_raw": time_site, "date_raw": time_site,
"score_add": score_add, "score_add": score_add,
"source_key": source_key,
} }
) )
@@ -162,12 +159,18 @@ def parse_schedule(html: str) -> list[dict]:
return matches_data return matches_data
def run_parser_schedule() -> None: def run_parser_schedule(source_key: str | None = None) -> None:
html = fetch_html(URL_SCHEDULE) source = get_parser_source(source_key)
matches_data = parse_schedule(html) 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: if matches_data:
sync_matches(matches_data) sync_matches(matches_data)
print(f"[parser_schedule] Matches synced: {len(matches_data)}") print(f"[parser_schedule] Matches synced: {len(matches_data)}")
else:
print("[parser_schedule] Матчи не найдены")
if __name__ == "__main__": if __name__ == "__main__":

230
parsers/parser_sources.py Normal file
View File

@@ -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")

View File

@@ -2,9 +2,8 @@ import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from services.standings_service import sync_standings 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: def fetch_html(url: str) -> str:
@@ -65,16 +64,22 @@ def parse_standings(html: str) -> list[dict]:
return standings return standings
def run_parser_standings() -> None: def run_parser_standings(source_key: str | None = None) -> None:
html = fetch_html(URL_STANDINGS) 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) standings_rows = parse_standings(html)
if standings_rows: if standings_rows:
sync_standings( sync_standings(
season=SEASON, season=source["season"],
standings_rows=standings_rows, standings_rows=standings_rows,
) )
print(f"[parser_standings] Synced rows: {len(standings_rows)}") print(f"[parser_standings] Synced rows: {len(standings_rows)}")
else:
print("[parser_standings] Строки турнирной таблицы не найдены")
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -3,9 +3,7 @@ from bs4 import BeautifulSoup
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from services.teams_service import sync_teams from services.teams_service import sync_teams
from parsers.parser_sources import get_parser_source, source_absolute_url
TEAMS_URL = "https://wfl.rfs.ru/tournament/1061879/teams"
def fetch_html(url: str) -> str: def fetch_html(url: str) -> str:
@@ -15,45 +13,51 @@ def fetch_html(url: str) -> str:
return response.text return response.text
def get_links(html) -> list: def get_links(html: str, source: dict) -> list[str]:
soup = BeautifulSoup(html, "html.parser") soup = BeautifulSoup(html, "html.parser")
links: list[dict] = [] links: list[str] = []
items = soup.find("ul", class_="teams__list").find_all("li")
for i in items: teams_list = soup.find("ul", class_="teams__list")
links.append( if not teams_list:
"https://wfl.rfs.ru/team/" return links
+ i.find("a", class_="teams__link").get("href").split("team_id=")[-1]
) 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 return links
def get_url_teams() -> list[dict]: def get_url_teams(source_key: str | None = None) -> list[dict]:
html = fetch_html(TEAMS_URL) source = get_parser_source(source_key)
links = get_links(html) html = fetch_html(source["teams_url"])
links = get_links(html, source)
teams_data: list[dict] = [] teams_data: list[dict] = []
with ThreadPoolExecutor() as pool: with ThreadPoolExecutor() as pool:
responses = [ responses = [pool.submit(fetch_html, link) for link in links]
pool.submit(
fetch_html,
link,
)
for link in links
]
for result in responses: for result in responses:
try: try:
html = result.result() html = result.result()
team_data = parse_teams_html(html) team_data = parse_teams_html(html)
teams_data.append(team_data) teams_data.append(team_data)
except Exception as e: except Exception as e:
print(f"Error fetching team data: {e}") print(f"[parser_teams] Error fetching team data: {e}")
return teams_data return teams_data
def parse_teams_html(html: str) -> dict: def parse_teams_html(html: str) -> dict:
soup = BeautifulSoup(html, "html.parser") soup = BeautifulSoup(html, "html.parser")
teams_data: dict = {}
name = soup.find("a", class_="team-promo__team-name").text.strip() name = soup.find("a", class_="team-promo__team-name").text.strip()
external_id = soup.find("a", class_="team-promo__logo").get("href").split("/")[-1] external_id = soup.find("a", class_="team-promo__logo").get("href").split("/")[-1]
stat_info = soup.find("ul", class_="stats-info").find_all( 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() goals = stat_info[2].text.strip()
tournaments = stat_info[3].text.strip() tournaments = stat_info[3].text.strip()
teams_data = { return {
"external_id": str(external_id), "external_id": str(external_id),
"name": name, "name": name,
"logo_url": logo_url, "logo_url": logo_url,
@@ -75,14 +79,18 @@ def parse_teams_html(html: str) -> dict:
"tournaments": tournaments, "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(source_key)
teams_data = get_url_teams()
if teams_data: if teams_data:
sync_teams(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__": if __name__ == "__main__":

View File

@@ -5,6 +5,24 @@ from repositories.team_repository import get_team_id_by_external_id
from repositories.stadium_repository import get_or_create_stadium 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( def upsert_match(
external_id: str, external_id: str,
@@ -20,6 +38,7 @@ def upsert_match(
stadium_id: int | None = None, stadium_id: int | None = None,
date_raw: str | None = None, date_raw: str | None = None,
score_add: str | None = None, score_add: str | None = None,
source_key: str | None = None,
) -> None: ) -> None:
query = """ query = """
INSERT INTO matches ( INSERT INTO matches (
@@ -36,10 +55,11 @@ INSERT INTO matches (
stadium_id, stadium_id,
date_raw, date_raw,
score_add, score_add,
source_key,
created_at, created_at,
updated_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) ON CONFLICT (external_id)
DO UPDATE SET DO UPDATE SET
home_team_id = EXCLUDED.home_team_id, home_team_id = EXCLUDED.home_team_id,
@@ -54,6 +74,7 @@ DO UPDATE SET
stadium_id = EXCLUDED.stadium_id, stadium_id = EXCLUDED.stadium_id,
date_raw = EXCLUDED.date_raw, date_raw = EXCLUDED.date_raw,
score_add = EXCLUDED.score_add, score_add = EXCLUDED.score_add,
source_key = COALESCE(EXCLUDED.source_key, matches.source_key),
updated_at = NOW(); updated_at = NOW();
""" """
@@ -76,6 +97,7 @@ DO UPDATE SET
stadium_id, stadium_id,
date_raw, date_raw,
score_add, score_add,
source_key,
), ),
) )
conn.commit() conn.commit()
@@ -100,6 +122,7 @@ def upsert_match_by_team_external_ids(
stadium_id: int | None = None, stadium_id: int | None = None,
date_raw: str | None = None, date_raw: str | None = None,
score_add: str | None = None, score_add: str | None = None,
source_key: str | None = None,
) -> None: ) -> None:
home_team_id = get_team_id_by_external_id(home_team_external_id) 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) 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, stadium_id=stadium_id,
date_raw=date_raw, date_raw=date_raw,
score_add=score_add, score_add=score_add,
source_key=source_key,
) )

View File

@@ -1,6 +1,7 @@
import secrets import secrets
from db import get_connection from db import get_connection
from parsers.parser_sources import build_logo_path
def create_match_session( def create_match_session(
@@ -66,7 +67,8 @@ def get_match_session_by_token(session_token: str):
at.id AS away_team_id, at.id AS away_team_id,
at.name AS away_team_name, at.name AS away_team_name,
at.logo_url AS away_team_logo, 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 FROM match_sessions ms
JOIN matches m ON m.id = ms.match_id JOIN matches m ON m.id = ms.match_id
@@ -80,7 +82,16 @@ def get_match_session_by_token(session_token: str):
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(query, (session_token,)) 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: finally:
conn.close() conn.close()

View File

@@ -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()

View File

@@ -1,4 +1,5 @@
from db import get_connection from db import get_connection
from parsers.parser_sources import extract_logo_filename
def upsert_team( def upsert_team(
@@ -153,7 +154,7 @@ def search_teams_for_admin(q: str = "") -> list[dict]:
"full_name": row[2] or "", "full_name": row[2] or "",
"short_name_3": row[3] or "", "short_name_3": row[3] or "",
"city": row[4] or "", "city": row[4] or "",
"logo_path": row[5] or "", "logo_path": extract_logo_filename(row[5]),
"external_id": row[6] or "", "external_id": row[6] or "",
} }
for row in rows for row in rows
@@ -192,7 +193,7 @@ def get_team_by_id(team_id: int) -> dict | None:
"full_name": row[2] or "", "full_name": row[2] or "",
"short_name_3": row[3] or "", "short_name_3": row[3] or "",
"city": row[4] or "", "city": row[4] or "",
"logo_path": row[5] or "", "logo_path": extract_logo_filename(row[5]),
"external_id": row[6] or "", "external_id": row[6] or "",
} }
finally: finally:
@@ -227,7 +228,7 @@ def update_team_admin(
full_name.strip(), full_name.strip(),
short_name_3.strip().upper(), short_name_3.strip().upper(),
city.strip(), city.strip(),
logo_path.strip(), extract_logo_filename(logo_path),
external_id.strip(), external_id.strip(),
team_id, team_id,
), ),

View File

@@ -12,6 +12,8 @@ from parsers.parser_standings import run_parser_standings
import requests import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from source_config import match_url
TZ = ZoneInfo("Europe/Moscow") TZ = ZoneInfo("Europe/Moscow")
@@ -202,7 +204,7 @@ def parse_score(value: str) -> int | None:
def fetch_match_live_data(match_id: int) -> dict: 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") soup = BeautifulSoup(html, "html.parser")
score_box = soup.find("div", class_="score__container") score_box = soup.find("div", class_="score__container")

View File

@@ -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

View File

@@ -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

View File

@@ -16,4 +16,5 @@ def sync_matches(matches_data: list[dict]) -> None:
place=match.get("place"), place=match.get("place"),
date_raw=match.get("date_raw"), date_raw=match.get("date_raw"),
score_add=match.get("score_add"), score_add=match.get("score_add"),
) source_key=match.get("source_key"),
)

View File

@@ -1,27 +1,44 @@
# services/vmix_json_service.py # services/vmix_json_service.py
from db import get_connection from db import get_connection
from repositories.match_lineup_repository import get_match_lineup_for_vmix 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" DEFAULT_PHOTO_BASE_PATH = r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo"
EMPTY_PHOTO_PATH = PHOTO_BASE_PATH + r"\EMPTY.png" 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: def _session_source_key(session_row) -> str | None:
return ( try:
PHOTO_BASE_PATH value = session_row[21]
+ "\\" except Exception:
+ str(team_name or "") 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() + (str(last_name or "") + " " + str(first_name or "")).strip()
+ ".png" + ".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: if photo_enabled:
return generated_photo return generated_photo
return EMPTY_PHOTO_PATH return build_empty_photo_path(source_key) or EMPTY_PHOTO_PATH
def _normalize_text(value) -> str: def _normalize_text(value) -> str:
@@ -208,6 +225,7 @@ def resolve_player_photo_for_json(
player: dict, player: dict,
generated_photo: str, generated_photo: str,
fallback_team_id=None, fallback_team_id=None,
source_key: str | None = None,
) -> str: ) -> str:
state = _find_player_photo_state( state = _find_player_photo_state(
player_id=player.get("player_id") or player.get("id"), 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: if state is not None:
_photo, photo_enabled = state photo_value, photo_enabled = state
return resolve_player_photo(photo_enabled=photo_enabled, generated_photo=generated_photo) 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( return resolve_player_photo(
photo_enabled=player.get("photo_enabled"), 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( lineups = get_match_lineup_for_vmix(
match_id=match_id, match_id=match_id,
home_team_id=home_team_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, team_name=team_a_name if "home" in name else team_b_name,
last_name=p.get("last_name", ""), last_name=p.get("last_name", ""),
first_name=p.get("first_name", ""), first_name=p.get("first_name", ""),
source_key=source_key,
), ),
fallback_team_id=fallback_team_id, 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, m.tour,
ht.logo_path AS home_logo, 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, ref1.referee_name AS referee1,
ref2.referee_name AS referee2, ref2.referee_name AS referee2,
ref3.referee_name AS referee3, ref3.referee_name AS referee3,
ref4.referee_name AS referee4, ref4.referee_name AS referee4,
CASE ht.logo_path AS home_logo1,
WHEN ht.name ILIKE '%%динамо%%' at.logo_path AS away_logo1,
THEN REPLACE(REPLACE(ht.logo_path, 'HOME\\', ''), 'Динамо', 'Динамоелый') ht.logo_path AS home_logo2,
WHEN ht.name ILIKE '%%зенит%%' at.logo_path AS away_logo2,
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.city AS home_city, ht.city AS home_city,
at.city AS away_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, c1.amplua AS coach_amplua1,
TRIM(COALESCE(c2.name, '') || ' ' || COALESCE(c2.lastname, '')) AS coach_name2, 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 FROM match_sessions ms
JOIN matches m ON m.id = ms.match_id JOIN matches m ON m.id = ms.match_id
@@ -378,23 +380,33 @@ def get_vmix_match_info_by_token(session_token: str):
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(query, (session_token,)) 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: finally:
conn.close() 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 = """ query = """
SELECT SELECT
s.position, s.position,
t.full_name, COALESCE(t.full_name, t.name) AS team_name,
CASE t.logo_path AS logo,
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,
s.played, s.played,
s.wins, s.wins,
s.losses, s.losses,
@@ -404,35 +416,36 @@ def get_vmix_standings(session_token: str):
s.team_id s.team_id
FROM standings s FROM standings s
LEFT JOIN teams t ON s.team_id = t.id LEFT JOIN teams t ON s.team_id = t.id
WHERE (%s IS NULL OR s.season = %s)
ORDER BY s.position ORDER BY s.position
""" """
conn = get_connection() conn = get_connection()
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(query, (session_token,)) cur.execute(query, (season, season))
return cur.fetchall() 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: finally:
conn.close() 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 = """ query = """
SELECT SELECT
CASE t1.logo_path AS logo1,
WHEN t1.full_name ILIKE '%%динамо%%' t2.logo_path AS logo2,
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,
m.home_score, m.home_score,
m.away_score, m.away_score,
m.match_date, 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 t1 ON m.home_team_id = t1.id
LEFT JOIN teams t2 ON m.away_team_id = t2.id LEFT JOIN teams t2 ON m.away_team_id = t2.id
WHERE m.tour = %s 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 ORDER BY m.match_date, m.id
""" """
conn = get_connection() conn = get_connection()
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(query, (session_token[10],)) cur.execute(query, (tour, season, season, source_filter, source_filter))
return cur.fetchall() 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: finally:
conn.close() conn.close()

32
source_config.py Normal file
View File

@@ -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)

View File

@@ -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);

View File

@@ -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;

View File

@@ -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) = '');

View File

@@ -1260,14 +1260,28 @@ function closeGoalEditor() {
function getVmixTeamLogo(teamName) { 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"; const basePath = "D:\\Графика\\ФУТБОЛ\\Женская Суперлига 2026\\Teams Logos";
let clean = String(teamName || "").replaceAll("«", "").replaceAll("»", "").trim(); let clean = normalizedTeamName.replaceAll("«", "").replaceAll("»", "").trim();
if (clean === "Зенит" || clean === "Динамо") { if (clean === "Зенит" || clean === "Динамо") {
clean += "_Синий"; clean += "_Синий";
} }
return `${basePath}\\${clean}.png`; return clean ? `${basePath}\\${clean}.png` : "";
} }
function getScoreSum() { function getScoreSum() {
@@ -4063,4 +4077,4 @@ function closeClearMatchModal() {
function confirmClearMatch() { function confirmClearMatch() {
closeClearMatchModal(); closeClearMatchModal();
clearMatchEvents(); clearMatchEvents();
} }

View File

@@ -107,6 +107,82 @@
margin: 0; 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 { .log-block {
margin-top: 20px; margin-top: 20px;
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -151,7 +227,8 @@
} }
.account-modal, .account-modal,
.update-modal { .update-modal,
.settings-modal {
position: fixed; position: fixed;
inset: 0; inset: 0;
display: none; display: none;
@@ -163,12 +240,14 @@
} }
.account-modal.active, .account-modal.active,
.update-modal.active { .update-modal.active,
.settings-modal.active {
display: flex; display: flex;
} }
.account-modal-card, .account-modal-card,
.update-modal-card { .update-modal-card,
.settings-modal-card {
width: min(100%, 460px); width: min(100%, 460px);
background: rgba(12, 17, 26, 0.98); background: rgba(12, 17, 26, 0.98);
border: 1px solid rgba(0, 255, 136, 0.28); border: 1px solid rgba(0, 255, 136, 0.28);
@@ -177,21 +256,24 @@
padding: 24px; padding: 24px;
} }
.update-modal-card { .update-modal-card,
.settings-modal-card {
width: min(100%, 760px); width: min(100%, 760px);
max-height: 92vh; max-height: 92vh;
overflow-y: auto; overflow-y: auto;
} }
.account-modal-title, .account-modal-title,
.update-modal-title { .update-modal-title,
.settings-modal-title {
font-size: 22px; font-size: 22px;
font-weight: 700; font-weight: 700;
margin-bottom: 8px; margin-bottom: 8px;
} }
.account-modal-subtitle, .account-modal-subtitle,
.update-modal-subtitle { .update-modal-subtitle,
.settings-modal-subtitle {
color: var(--muted); color: var(--muted);
margin-bottom: 18px; margin-bottom: 18px;
line-height: 1.5; line-height: 1.5;
@@ -202,6 +284,46 @@
gap: 14px; 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 { .field-label {
display: block; display: block;
font-size: 13px; font-size: 13px;
@@ -407,24 +529,66 @@
</div> </div>
<div class="parser-actions"> <div class="parser-actions">
<form method="post" action="/admin/db/run-parser" class="action-form" data-parser-title="Игроки" data-parser-status="Сбор и сохранение базы игроков..."> <form method="post" action="/admin/db/run-parser" class="action-form parser-import-card" data-parser-title="Выбранные данные" data-parser-status="Загрузка выбранных данных из выбранного турнира...">
<input type="hidden" name="parser_name" value="players"> <div class="parser-import-title">Импорт данных</div>
<button type="submit" class="action-btn">🗄️ Заграбить игроков</button> <div class="parser-import-subtitle">
</form> Выберите источник турнира и отметьте, какие данные нужно заграбить. Существующие команды, игроки и матчи будут обновлены через UPSERT, без полной очистки таблиц.
</div>
<form method="post" action="/admin/db/run-parser" class="action-form" data-parser-title="Расписание" data-parser-status="Загрузка расписания матчей с сайта..."> <div class="parser-import-grid">
<input type="hidden" name="parser_name" value="schedule"> <div>
<button type="submit" class="action-btn">🗄️ Заграбить расписание</button> <label class="field-label" for="parserSource">Источник / турнир</label>
</form> {% set sources = parser_sources.values() if parser_sources is mapping else parser_sources|default([]) %}
<select class="field-select" id="parserSource" name="parser_source" required>
{% if sources %}
{% for source in sources %}
<option value="{{ source.key }}" data-logo-base-path="{{ source.logo_base_path or '' }}" data-photo-base-path="{{ source.photo_base_path or '' }}" {% if source.key == default_parser_source_key %}selected{% endif %}>
{{ source.title }}
</option>
{% endfor %}
{% else %}
<option value="SUPERLEAGUE" data-logo-base-path="D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Teams Logos" data-photo-base-path="D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo" selected>Суперлига 2026</option>
<option value="RUSSIAN_CUP" data-logo-base-path="D:\Графика\ФУТБОЛ\Кубок России 2026\Teams Logos" data-photo-base-path="D:\Графика\ФУТБОЛ\Кубок России 2026\Photo">Кубок России 2026</option>
{% endif %}
</select>
<div class="helper-text">Список источников хранится в <b>базе данных</b> и редактируется в настройках проекта.</div>
<div class="helper-text">Путь к логотипам: <b id="selectedLogoBasePath"></b></div>
<div class="helper-text">Путь к фотографиям: <b id="selectedPhotoBasePath"></b></div>
</div>
<form method="post" action="/admin/db/run-parser" class="action-form" data-parser-title="Турнирка" data-parser-status="Обновление турнирной таблицы..."> <div>
<input type="hidden" name="parser_name" value="standings"> <label class="field-label">Что парсить</label>
<button type="submit" class="action-btn">🗄️ Заграбить турнирку</button> <div class="parser-checkboxes">
<label class="parser-checkbox">
<input type="checkbox" name="parser_names" value="teams" checked>
<span>Команды</span>
</label>
<label class="parser-checkbox">
<input type="checkbox" name="parser_names" value="players" checked>
<span>Игроки</span>
</label>
<label class="parser-checkbox">
<input type="checkbox" name="parser_names" value="schedule" checked>
<span>Расписание</span>
</label>
<label class="parser-checkbox">
<input type="checkbox" name="parser_names" value="standings">
<span>Турнирка</span>
</label>
</div>
</div>
</div>
<div class="parser-submit-row">
<button type="submit" class="action-btn">🗄️ Заграбить выбранное</button>
<div class="parser-mode-note">Безопасный режим: добавляет новые записи и обновляет существующие. Турнирка по-прежнему пересобирается только за выбранный сезон.</div>
</div>
</form> </form>
</div> </div>
<div class="account-actions"> <div class="account-actions">
<button type="button" class="account-toggle-btn" id="openCreateAccountBtn"> Создать аккаунт</button> <button type="button" class="account-toggle-btn" id="openCreateAccountBtn"> Создать аккаунт</button>
<button type="button" class="account-toggle-btn" id="openSettingsBtn">⚙️ Настройки проекта</button>
<button type="button" class="account-toggle-btn" id="openAddUpdateBtn">📝 Добавить обновление</button> <button type="button" class="account-toggle-btn" id="openAddUpdateBtn">📝 Добавить обновление</button>
</div> </div>
@@ -458,6 +622,21 @@
</div> </div>
{% endif %} {% endif %}
{% if settings_output %}
<div class="log-block {% if settings_success %}success{% else %}error{% endif %}">
<div class="log-title">Сохранение настроек проекта</div>
<div class="log-status">
Статус:
{% if settings_success %}
успешно
{% else %}
ошибка
{% endif %}
</div>
<pre class="log-output">{{ settings_output }}</pre>
</div>
{% endif %}
{% if update_output %} {% if update_output %}
<div class="log-block {% if update_success %}success{% else %}error{% endif %}"> <div class="log-block {% if update_success %}success{% else %}error{% endif %}">
<div class="log-title">Добавление обновления</div> <div class="log-title">Добавление обновления</div>
@@ -510,6 +689,58 @@
</div> </div>
<div id="settingsModal" class="settings-modal" aria-hidden="true">
<div class="settings-modal-card">
<div class="settings-modal-title">Настройки проекта</div>
<div class="settings-modal-subtitle">
Здесь редактируются источники парсинга: ссылки, сезон, тип календаря, папки логотипов и фотографий. Эти данные хранятся в базе данных.
<br>.env оставляем только для NAS и подключения к базе.
</div>
<form method="post" action="/admin/db/project-settings" id="settingsForm">
{% if project_settings and project_settings.groups %}
<div class="env-groups">
{% for group in project_settings.groups %}
<div class="env-group">
<div class="env-group-title">{{ group.title }}</div>
{% if group.subtitle %}
<div class="env-group-subtitle">{{ group.subtitle }}</div>
{% endif %}
<div class="env-grid">
{% for field in group.fields %}
<div class="{% if field.wide %}env-field-wide{% endif %}">
<label class="field-label" for="env_{{ field.key }}">{{ field.label }}</label>
{% if field.type == 'select' %}
<select class="field-select" id="env_{{ field.key }}" name="{{ field.key }}">
{% for option in field.options %}
<option value="{{ option.value }}" {% if option.value == field.value %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
{% else %}
<input class="field-input" id="env_{{ field.key }}" type="text" name="{{ field.key }}" value="{{ field.value }}">
{% endif %}
{% if field.help %}
<div class="helper-text">{{ field.help }}</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="helper-text">Не удалось загрузить список настроек проекта из базы данных.</div>
{% endif %}
<div class="modal-actions">
<button type="button" class="cancel-btn" id="closeSettingsBtn">Отмена</button>
<button type="submit" class="submit-btn">Сохранить настройки</button>
</div>
</form>
</div>
</div>
<div id="updateModal" class="update-modal" aria-hidden="true"> <div id="updateModal" class="update-modal" aria-hidden="true">
<div class="update-modal-card"> <div class="update-modal-card">
<div class="update-modal-title">Добавить обновление</div> <div class="update-modal-title">Добавить обновление</div>
@@ -617,9 +848,15 @@
const canvas = document.getElementById("matrixCanvas"); const canvas = document.getElementById("matrixCanvas");
const ctx = canvas ? canvas.getContext("2d") : null; const ctx = canvas ? canvas.getContext("2d") : null;
const parserForms = document.querySelectorAll(".action-form"); const parserForms = document.querySelectorAll(".action-form");
const parserSourceSelect = document.getElementById("parserSource");
const selectedLogoBasePath = document.getElementById("selectedLogoBasePath");
const selectedPhotoBasePath = document.getElementById("selectedPhotoBasePath");
const openCreateAccountBtn = document.getElementById("openCreateAccountBtn"); const openCreateAccountBtn = document.getElementById("openCreateAccountBtn");
const closeCreateAccountBtn = document.getElementById("closeCreateAccountBtn"); const closeCreateAccountBtn = document.getElementById("closeCreateAccountBtn");
const accountModal = document.getElementById("accountModal"); const accountModal = document.getElementById("accountModal");
const openSettingsBtn = document.getElementById("openSettingsBtn");
const closeSettingsBtn = document.getElementById("closeSettingsBtn");
const settingsModal = document.getElementById("settingsModal");
const openAddUpdateBtn = document.getElementById("openAddUpdateBtn"); const openAddUpdateBtn = document.getElementById("openAddUpdateBtn");
const closeAddUpdateBtn = document.getElementById("closeAddUpdateBtn"); const closeAddUpdateBtn = document.getElementById("closeAddUpdateBtn");
const updateModal = document.getElementById("updateModal"); const updateModal = document.getElementById("updateModal");
@@ -695,6 +932,18 @@
accountModal.setAttribute("aria-hidden", "true"); accountModal.setAttribute("aria-hidden", "true");
} }
function openSettingsModal() {
if (!settingsModal) return;
settingsModal.classList.add("active");
settingsModal.setAttribute("aria-hidden", "false");
}
function closeSettingsModal() {
if (!settingsModal) return;
settingsModal.classList.remove("active");
settingsModal.setAttribute("aria-hidden", "true");
}
function openUpdateModal() { function openUpdateModal() {
if (!updateModal) return; if (!updateModal) return;
if (updateDate && !updateDate.value) { if (updateDate && !updateDate.value) {
@@ -742,6 +991,17 @@
textarea.setSelectionRange(cursorPosition, cursorPosition); textarea.setSelectionRange(cursorPosition, cursorPosition);
} }
function updateSelectedSourcePaths() {
if (!parserSourceSelect) return;
const option = parserSourceSelect.options[parserSourceSelect.selectedIndex];
if (selectedLogoBasePath) {
selectedLogoBasePath.textContent = option?.dataset?.logoBasePath || "—";
}
if (selectedPhotoBasePath) {
selectedPhotoBasePath.textContent = option?.dataset?.photoBasePath || "—";
}
}
document.addEventListener("click", (event) => { document.addEventListener("click", (event) => {
const button = event.target.closest(".format-btn"); const button = event.target.closest(".format-btn");
if (!button) return; if (!button) return;
@@ -757,6 +1017,9 @@
insertSnippet(textarea, snippet); insertSnippet(textarea, snippet);
}); });
updateSelectedSourcePaths();
parserSourceSelect?.addEventListener("change", updateSelectedSourcePaths);
parserForms.forEach((form) => { parserForms.forEach((form) => {
form.addEventListener("submit", () => { form.addEventListener("submit", () => {
const parserTitle = form.dataset.parserTitle || "Запуск парсера"; const parserTitle = form.dataset.parserTitle || "Запуск парсера";
@@ -786,6 +1049,22 @@
}); });
} }
if (openSettingsBtn) {
openSettingsBtn.addEventListener("click", openSettingsModal);
}
if (closeSettingsBtn) {
closeSettingsBtn.addEventListener("click", closeSettingsModal);
}
if (settingsModal) {
settingsModal.addEventListener("click", (event) => {
if (event.target === settingsModal) {
closeSettingsModal();
}
});
}
if (openAddUpdateBtn) { if (openAddUpdateBtn) {
openAddUpdateBtn.addEventListener("click", openUpdateModal); openAddUpdateBtn.addEventListener("click", openUpdateModal);
} }
@@ -809,6 +1088,7 @@
document.addEventListener("keydown", (event) => { document.addEventListener("keydown", (event) => {
if (event.key === "Escape") { if (event.key === "Escape") {
closeAccountModal(); closeAccountModal();
closeSettingsModal();
closeUpdateModal(); closeUpdateModal();
} }
}); });

View File

@@ -229,13 +229,13 @@
</div> </div>
<div class="form-group full"> <div class="form-group full">
<label>Фото игрока</label> <label>Файл фото игрока</label>
<input type="text" name="photo" value="{{ player.photo }}"> <input type="text" name="photo" value="{{ player.photo }}" placeholder="Например: Динамо\Иванов Иван.png или Иванов Иван.png">
<label class="checkbox-row"> <label class="checkbox-row">
<input type="checkbox" name="photo_enabled" value="true" {% if player.photo_enabled %}checked{% endif %}> <input type="checkbox" name="photo_enabled" value="true" {% if player.photo_enabled %}checked{% endif %}>
Использовать фото в JSON/vMix Использовать фото в JSON/vMix
</label> </label>
<div class="hint">Если галочка включена, путь к фото в JSON генерируется по старой схеме: Photo\Команда\Фамилия Имя.png. Если выключена — отдается EMPTY.png.</div> <div class="hint">Если поле пустое, путь генерируется автоматически: папка фотографий источника + Команда\Фамилия Имя.png. Если поле заполнено, можно указать имя файла или относительный путь. Если галочка выключена — отдается EMPTY.png из папки выбранного источника.</div>
{% if player.photo %} {% if player.photo %}
<div class="media-preview-box"> <div class="media-preview-box">
<img src="{{ player.photo }}" alt="{{ player.full_name }}" class="media-preview-image"> <img src="{{ player.photo }}" alt="{{ player.full_name }}" class="media-preview-image">

View File

@@ -27,19 +27,6 @@
background: var(--panel-2); background: var(--panel-2);
color: var(--text); color: var(--text);
} }
.logo-box {
margin-top: 8px;
padding: 12px;
border: 1px dashed var(--border);
border-radius: 12px;
background: var(--panel-2);
}
.logo-preview {
max-width: 120px;
max-height: 120px;
object-fit: contain;
display: block;
}
.actions { margin-top: 20px; display: flex; gap: 10px; } .actions { margin-top: 20px; display: flex; gap: 10px; }
.btn { min-height: 42px; padding: 0 14px; border: none; border-radius: 10px; cursor: pointer; font-weight: 600; text-decoration: none; display: inline-flex; align-items: center; } .btn { min-height: 42px; padding: 0 14px; border: none; border-radius: 10px; cursor: pointer; font-weight: 600; text-decoration: none; display: inline-flex; align-items: center; }
.btn-primary { background: var(--accent); color: white; } .btn-primary { background: var(--accent); color: white; }
@@ -80,14 +67,10 @@
</div> </div>
<div class="form-group full"> <div class="form-group full">
<label>Путь к логотипу</label> <label>Файл логотипа</label>
<input type="text" name="logo_path" value="{{ team.logo_path }}"> <input type="text" name="logo_path" value="{{ team.logo_path }}" placeholder="Например: Динамо_Синий.png">
<div class="logo-box"> <div class="small-meta">
{% if team.logo_path %} В команде хранится только имя файла. Полный путь подставляется автоматически по источнику выбранного матча.
<img src="{{ team.logo_path }}" alt="{{ team.name }}" class="logo-preview">
{% else %}
Логотип не задан.
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -26,13 +26,6 @@
tr:last-child td { border-bottom: none; } tr:last-child td { border-bottom: none; }
.id-col { width: 70px; } .id-col { width: 70px; }
.action-col { width: 140px; text-align: right; } .action-col { width: 140px; text-align: right; }
.logo-preview {
width: 28px;
height: 28px;
object-fit: contain;
display: inline-block;
vertical-align: middle;
}
.empty-box { padding: 18px; border-radius: 12px; background: var(--panel-2); color: var(--muted); border: 1px dashed var(--border); } .empty-box { padding: 18px; border-radius: 12px; background: var(--panel-2); color: var(--muted); border: 1px dashed var(--border); }
</style> </style>
</head> </head>
@@ -57,7 +50,7 @@
<th>Полное название</th> <th>Полное название</th>
<th>Город</th> <th>Город</th>
<th>3 буквы</th> <th>3 буквы</th>
<th>Логотип</th> <th>Файл логотипа</th>
<th>External ID</th> <th>External ID</th>
<th class="action-col"></th> <th class="action-col"></th>
</tr> </tr>
@@ -70,13 +63,7 @@
<td>{{ t.full_name or "—" }}</td> <td>{{ t.full_name or "—" }}</td>
<td>{{ t.city or "—" }}</td> <td>{{ t.city or "—" }}</td>
<td>{{ t.short_name_3 or "—" }}</td> <td>{{ t.short_name_3 or "—" }}</td>
<td> <td>{{ t.logo_path or "—" }}</td>
{% if t.logo_path %}
<img src="{{ t.logo_path }}" alt="{{ t.name }}" class="logo-preview">
{% else %}
{% endif %}
</td>
<td>{{ t.external_id }}</td> <td>{{ t.external_id }}</td>
<td class="action-col"> <td class="action-col">
<a href="/admin/db/teams/{{ t.id }}/edit" class="btn btn-secondary">Редактировать</a> <a href="/admin/db/teams/{{ t.id }}/edit" class="btn btn-secondary">Редактировать</a>

BIN
vMix.zip Normal file

Binary file not shown.

View File

@@ -13,6 +13,27 @@ SYNO_URL = os.getenv("SYNO_URL")
SYNO_USERNAME = os.getenv("SYNO_USERNAME") SYNO_USERNAME = os.getenv("SYNO_USERNAME")
SYNO_PASSWORD = os.getenv("SYNO_PASSWORD") SYNO_PASSWORD = os.getenv("SYNO_PASSWORD")
SYNO_PATH_VMIX = os.getenv("SYNO_PATH_VMIX") SYNO_PATH_VMIX = os.getenv("SYNO_PATH_VMIX")
SYNO_PATH_VMIX_2 = os.getenv("SYNO_PATH_VMIX_2")
def normalize_source_key(source_key: str | None) -> str:
return str(source_key or "").upper().strip()
def get_vmix_preset_path(source_key: str | None = None) -> str | None:
"""Выбирает NAS-путь к vMix-пресету по источнику матча.
SUPERLEAGUE -> SYNO_PATH_VMIX
RUSSIAN_CUP -> SYNO_PATH_VMIX_2, если он заполнен
Если второй путь не задан, используем основной, чтобы скачивание не ломалось.
"""
key = normalize_source_key(source_key)
if key == "RUSSIAN_CUP" and SYNO_PATH_VMIX_2:
return SYNO_PATH_VMIX_2
return SYNO_PATH_VMIX
def get_fqdn(): def get_fqdn():
@@ -145,13 +166,18 @@ def build_vmix_project_bytes(
session_token: str, session_token: str,
match_id: str | int | None = None, match_id: str | int | None = None,
operator_login: str | None = None, operator_login: str | None = None,
source_key: str | None = None,
) -> bytes: ) -> bytes:
vmix_preset_path = get_vmix_preset_path(source_key)
if not vmix_preset_path:
raise RuntimeError("Не задан путь к vMix-пресету: SYNO_PATH_VMIX или SYNO_PATH_VMIX_2")
vmix_bio = nasio.load_bio( vmix_bio = nasio.load_bio(
user=SYNO_USERNAME, user=SYNO_USERNAME,
password=SYNO_PASSWORD, password=SYNO_PASSWORD,
nas_ip=SYNO_URL, nas_ip=SYNO_URL,
nas_port="443", nas_port="443",
path=SYNO_PATH_VMIX, path=vmix_preset_path,
) )
edited_vmix = change_vmix_datasource_urls( edited_vmix = change_vmix_datasource_urls(

83
wfl.sql
View File

@@ -55,12 +55,14 @@ CREATE TABLE matches (
away_score INTEGER, away_score INTEGER,
tour VARCHAR(100), tour VARCHAR(100),
season VARCHAR(50), season VARCHAR(50),
source_key VARCHAR(50),
created_at TIMESTAMP NOT NULL DEFAULT NOW(), created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW() updated_at TIMESTAMP NOT NULL DEFAULT NOW()
); );
CREATE INDEX idx_matches_external_id ON matches(external_id); CREATE INDEX idx_matches_external_id ON matches(external_id);
CREATE INDEX idx_matches_season ON matches(season); CREATE INDEX idx_matches_season ON matches(season);
CREATE INDEX idx_matches_source_key ON matches(source_key);
CREATE INDEX idx_matches_date ON matches(match_date); CREATE INDEX idx_matches_date ON matches(match_date);
@@ -85,3 +87,84 @@ CREATE TABLE standings (
); );
CREATE INDEX idx_standings_season ON standings(season); CREATE INDEX idx_standings_season ON standings(season);
-- =========================
-- PROJECT SETTINGS / PARSER SOURCES
-- =========================
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;