обновление для Кубка России
переделаны все парсеры на ссылки из базы
This commit is contained in:
4
agent.py
4
agent.py
@@ -13,8 +13,8 @@ import requests
|
||||
import websockets
|
||||
|
||||
VMIX_API = "http://127.0.0.1:8088/api"
|
||||
WS_BASE = "wss://wfl.tvstart.ru/ws/vmix-client"
|
||||
# WS_BASE = "ws://127.0.0.1:8000/ws/vmix-client"
|
||||
# WS_BASE = "wss://wfl.tvstart.ru/ws/vmix-client"
|
||||
WS_BASE = "ws://127.0.0.1:8000/ws/vmix-client"
|
||||
|
||||
CLIENT_ID = f"vmix-{socket.gethostname().lower()}-{uuid.uuid4().hex[:6]}"
|
||||
POLL_INTERVAL = 3
|
||||
|
||||
180
app.py
180
app.py
@@ -33,6 +33,10 @@ from parsers.parser_game import run_parser_game
|
||||
from parsers.parser_players import run_parser_players
|
||||
from parsers.parser_schedule import run_parser_schedule
|
||||
from parsers.parser_standings import run_parser_standings
|
||||
from parsers.parser_teams import run_parser_teams
|
||||
from parsers.parser_sources import build_empty_photo_path, list_parser_sources, get_default_source_key, get_parser_source
|
||||
from services.project_settings_service import build_project_settings_context, save_project_settings
|
||||
from repositories.project_settings_repository import ensure_project_settings_tables
|
||||
|
||||
|
||||
from db import get_connection
|
||||
@@ -55,7 +59,7 @@ from repositories.match_session_repository import (
|
||||
from repositories.match_view_repository import get_match_lineups_grouped
|
||||
from vmix.vmix_service import build_vmix_project_bytes, build_vmix_filename
|
||||
from repositories.match_referee_repository import get_match_referees
|
||||
from repositories.match_repository import get_tour_schedule_by_match_id
|
||||
from repositories.match_repository import get_tour_schedule_by_match_id, ensure_match_source_key_column
|
||||
from repositories.standings_repository import get_standings_by_match_id
|
||||
from repositories.match_coach_repository import get_match_coaches_grouped
|
||||
from repositories.player_repository import (
|
||||
@@ -186,7 +190,9 @@ def start_scheduler():
|
||||
import threading
|
||||
|
||||
try:
|
||||
ensure_project_settings_tables()
|
||||
ensure_player_photo_enabled_column()
|
||||
ensure_match_source_key_column()
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
|
||||
@@ -700,10 +706,13 @@ def download_vmix_project(request: Request, session_token: str):
|
||||
operator_login = current_user.get("username") or None
|
||||
|
||||
try:
|
||||
source_key = session_row[21] if len(session_row) > 21 else None
|
||||
|
||||
vmix_bytes = build_vmix_project_bytes(
|
||||
session_token=session_token,
|
||||
match_id=session_row[1],
|
||||
operator_login=operator_login,
|
||||
source_key=source_key,
|
||||
)
|
||||
filename = build_vmix_filename(session_row)
|
||||
|
||||
@@ -852,16 +861,6 @@ def close_session(session_token: str):
|
||||
return RedirectResponse(url="/admin/matches", status_code=303)
|
||||
|
||||
|
||||
@app.get("/admin/db", response_class=HTMLResponse)
|
||||
def admin_db_index(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
name="admin_db_index.html",
|
||||
request=request,
|
||||
context={
|
||||
"current_user": getattr(request.state, "current_user", None),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/admin/db/players", response_class=HTMLResponse)
|
||||
def admin_db_players(
|
||||
@@ -1459,6 +1458,12 @@ EMPTY_PLAYER = {
|
||||
}
|
||||
|
||||
|
||||
def build_empty_player(source_key=None):
|
||||
item = EMPTY_PLAYER.copy()
|
||||
item["photo"] = build_empty_photo_path(source_key) or EMPTY_PHOTO_PATH
|
||||
return item
|
||||
|
||||
|
||||
def get_roster_data(session_token: str, name: str, count_player: int):
|
||||
session_row = get_match_session_by_token(session_token)
|
||||
if not session_row:
|
||||
@@ -1469,14 +1474,21 @@ def get_roster_data(session_token: str, name: str, count_player: int):
|
||||
home_team_name = session_row[14].replace("«", "").replace("»", "")
|
||||
away_team_id = session_row[17]
|
||||
away_team_name = session_row[18].replace("«", "").replace("»", "")
|
||||
source_key = session_row[21] if len(session_row) > 21 else None
|
||||
# print(session_row)
|
||||
|
||||
data = build_lineup_json(
|
||||
match_id, home_team_id, away_team_id, name, home_team_name, away_team_name
|
||||
match_id,
|
||||
home_team_id,
|
||||
away_team_id,
|
||||
name,
|
||||
home_team_name,
|
||||
away_team_name,
|
||||
source_key=source_key,
|
||||
)
|
||||
|
||||
players = data.get("players", [])
|
||||
players.extend([EMPTY_PLAYER.copy() for _ in range(count_player - len(players))])
|
||||
players.extend([build_empty_player(source_key) for _ in range(count_player - len(players))])
|
||||
|
||||
data["players"] = players[:count_player]
|
||||
return data
|
||||
@@ -1687,7 +1699,13 @@ EMPTY_PLAYER_FORMATION = {
|
||||
}
|
||||
|
||||
|
||||
def build_vmix_formation_response(rows, team_id, team_name):
|
||||
def build_empty_player_formation(source_key=None):
|
||||
item = EMPTY_PLAYER_FORMATION.copy()
|
||||
item["photo"] = build_empty_photo_path(source_key) or EMPTY_PHOTO_PATH
|
||||
return item
|
||||
|
||||
|
||||
def build_vmix_formation_response(rows, team_id, team_name, source_key=None):
|
||||
result = []
|
||||
|
||||
for p in rows:
|
||||
@@ -1719,18 +1737,21 @@ def build_vmix_formation_response(rows, team_id, team_name):
|
||||
"first_name": p[4] if len(p) > 4 else "",
|
||||
"number": number,
|
||||
"position": position,
|
||||
"photo": p[5] if len(p) > 5 else "",
|
||||
"photo_enabled": p[6] if len(p) > 6 else False,
|
||||
},
|
||||
generated_photo=build_generated_player_photo_path(
|
||||
team_name=team_name,
|
||||
last_name=lastname,
|
||||
first_name=p[4] if len(p) > 4 else "",
|
||||
source_key=source_key,
|
||||
),
|
||||
fallback_team_id=team_id,
|
||||
source_key=source_key,
|
||||
),
|
||||
}
|
||||
)
|
||||
result.extend([EMPTY_PLAYER_FORMATION.copy() for _ in range(11 - len(result))])
|
||||
result.extend([build_empty_player_formation(source_key) for _ in range(11 - len(result))])
|
||||
|
||||
return result
|
||||
|
||||
@@ -1743,8 +1764,9 @@ def vmix_home_formations(session_token: str):
|
||||
|
||||
home_team_id = session_row[13]
|
||||
home_team = session_row[14].replace("«", "").replace("»", "")
|
||||
source_key = session_row[21] if len(session_row) > 21 else None
|
||||
rows = get_vmix_team_formations(session_row, home_team_id)
|
||||
return build_vmix_formation_response(rows, home_team_id, home_team)
|
||||
return build_vmix_formation_response(rows, home_team_id, home_team, source_key=source_key)
|
||||
|
||||
|
||||
@app.get("/vmix/session/{session_token}/away-formations")
|
||||
@@ -1755,8 +1777,9 @@ def vmix_away_formations(session_token: str):
|
||||
|
||||
away_team_id = session_row[17]
|
||||
away_team = session_row[18].replace("«", "").replace("»", "")
|
||||
source_key = session_row[21] if len(session_row) > 21 else None
|
||||
rows = get_vmix_team_formations(session_row, away_team_id)
|
||||
return build_vmix_formation_response(rows, away_team_id, away_team)
|
||||
return build_vmix_formation_response(rows, away_team_id, away_team, source_key=source_key)
|
||||
|
||||
|
||||
@app.get("/vmix/session/{session_token}/scoreboard")
|
||||
@@ -2263,17 +2286,49 @@ def render_admin_db_index(
|
||||
parser_success: bool | None = None,
|
||||
update_output: str | None = None,
|
||||
update_success: bool | None = None,
|
||||
settings_output: str | None = None,
|
||||
settings_success: bool | None = None,
|
||||
):
|
||||
try:
|
||||
parser_sources = list_parser_sources()
|
||||
default_parser_source_key = get_default_source_key()
|
||||
except Exception:
|
||||
parser_sources = [
|
||||
{
|
||||
"key": "SUPERLEAGUE",
|
||||
"title": "Суперлига 2026",
|
||||
"logo_base_path": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Teams Logos",
|
||||
"photo_base_path": r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo",
|
||||
},
|
||||
{
|
||||
"key": "RUSSIAN_CUP",
|
||||
"title": "Кубок России 2026",
|
||||
"logo_base_path": r"D:\Графика\ФУТБОЛ\Кубок России 2026\Teams Logos",
|
||||
"photo_base_path": r"D:\Графика\ФУТБОЛ\Кубок России 2026\Photo",
|
||||
},
|
||||
]
|
||||
default_parser_source_key = "SUPERLEAGUE"
|
||||
|
||||
try:
|
||||
project_settings = build_project_settings_context()
|
||||
except Exception:
|
||||
project_settings = {"storage_title": "База данных", "groups": [], "editable_keys": []}
|
||||
|
||||
return templates.TemplateResponse(
|
||||
name="admin_db_index.html",
|
||||
request=request,
|
||||
context={
|
||||
"current_user": getattr(request.state, "current_user", None),
|
||||
"parser_sources": parser_sources,
|
||||
"default_parser_source_key": default_parser_source_key,
|
||||
"parser_output": parser_output,
|
||||
"parser_name": parser_name,
|
||||
"parser_success": parser_success,
|
||||
"update_output": update_output,
|
||||
"update_success": update_success,
|
||||
"settings_output": settings_output,
|
||||
"settings_success": settings_success,
|
||||
"project_settings": project_settings,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2284,46 +2339,79 @@ def admin_db_index(request: Request):
|
||||
|
||||
|
||||
@app.post("/admin/db/run-parser", response_class=HTMLResponse)
|
||||
def admin_db_run_parser(request: Request, parser_name: str = Form(...)):
|
||||
def admin_db_run_parser(
|
||||
request: Request,
|
||||
parser_name: str | None = Form(default=None),
|
||||
parser_names: List[str] = Form(default=[]),
|
||||
parser_source: str | None = Form(default=None),
|
||||
):
|
||||
current_user = getattr(request.state, "current_user", None)
|
||||
if not current_user or current_user.get("role") != "admin":
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
|
||||
parser_map = {
|
||||
"teams": ("Команды", run_parser_teams),
|
||||
"players": ("Игроки", run_parser_players),
|
||||
"schedule": ("Расписание", run_parser_schedule),
|
||||
"standings": ("Турнирка", run_parser_standings),
|
||||
}
|
||||
|
||||
parser_meta = parser_map.get(parser_name)
|
||||
if not parser_meta:
|
||||
selected_parser_names = list(parser_names or [])
|
||||
if not selected_parser_names and parser_name:
|
||||
selected_parser_names = [parser_name]
|
||||
|
||||
selected_parser_names = [name for name in selected_parser_names if name in parser_map]
|
||||
if not selected_parser_names:
|
||||
return render_admin_db_index(
|
||||
request,
|
||||
parser_output="Неизвестный парсер.",
|
||||
parser_name=parser_name,
|
||||
parser_output="Выберите хотя бы один тип данных для парсинга.",
|
||||
parser_name="Импорт данных",
|
||||
parser_success=False,
|
||||
)
|
||||
|
||||
title, parser_func = parser_meta
|
||||
try:
|
||||
source = get_parser_source(parser_source)
|
||||
except Exception as exc:
|
||||
return render_admin_db_index(
|
||||
request,
|
||||
parser_output=str(exc),
|
||||
parser_name="Импорт данных",
|
||||
parser_success=False,
|
||||
)
|
||||
|
||||
titles = [parser_map[name][0] for name in selected_parser_names]
|
||||
title = f"{source['title']}: {', '.join(titles)}"
|
||||
|
||||
buffer = io.StringIO()
|
||||
success = True
|
||||
|
||||
with contextlib.redirect_stdout(buffer), contextlib.redirect_stderr(buffer):
|
||||
print(
|
||||
f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Запуск парсера: {title}"
|
||||
)
|
||||
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Источник: {source['title']}")
|
||||
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Запуск: {', '.join(titles)}")
|
||||
|
||||
for selected_name in selected_parser_names:
|
||||
item_title, parser_func = parser_map[selected_name]
|
||||
print()
|
||||
print(f"===== {item_title} =====")
|
||||
try:
|
||||
parser_func()
|
||||
parser_func(source_key=source["key"])
|
||||
print(
|
||||
f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Парсер завершён успешно"
|
||||
f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {item_title}: успешно"
|
||||
)
|
||||
except Exception:
|
||||
success = False
|
||||
print(
|
||||
f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Ошибка при выполнении парсера"
|
||||
f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {item_title}: ошибка"
|
||||
)
|
||||
print(traceback.format_exc())
|
||||
|
||||
if success:
|
||||
print()
|
||||
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Импорт завершён успешно")
|
||||
else:
|
||||
print()
|
||||
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Импорт завершён с ошибками")
|
||||
|
||||
output = buffer.getvalue().strip() or "Парсер не вернул сообщений."
|
||||
return render_admin_db_index(
|
||||
request,
|
||||
@@ -2334,6 +2422,40 @@ def admin_db_run_parser(request: Request, parser_name: str = Form(...)):
|
||||
|
||||
|
||||
|
||||
@app.post("/admin/db/project-settings", response_class=HTMLResponse)
|
||||
async def admin_db_save_project_settings(request: Request):
|
||||
current_user = getattr(request.state, "current_user", None)
|
||||
if not current_user or current_user.get("role") != "admin":
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
|
||||
try:
|
||||
form = await request.form()
|
||||
form_values = {key: str(value) for key, value in form.items()}
|
||||
updated_keys = save_project_settings(form_values)
|
||||
|
||||
output = [
|
||||
"Настройки проекта сохранены в базе данных.",
|
||||
f"Сохранено полей: {len(updated_keys)}",
|
||||
]
|
||||
if updated_keys:
|
||||
output.append("Поля:")
|
||||
output.extend([f"- {key}" for key in updated_keys])
|
||||
output.append("")
|
||||
output.append(".env больше не используется для ссылок, сезонов, путей логотипов и фотографий.")
|
||||
|
||||
return render_admin_db_index(
|
||||
request,
|
||||
settings_output="\n".join(output),
|
||||
settings_success=True,
|
||||
)
|
||||
except Exception:
|
||||
return render_admin_db_index(
|
||||
request,
|
||||
settings_output="Ошибка при сохранении настроек проекта:\n" + traceback.format_exc(),
|
||||
settings_success=False,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/admin/db/add-update", response_class=HTMLResponse)
|
||||
def admin_db_add_update(
|
||||
request: Request,
|
||||
|
||||
@@ -2,8 +2,9 @@ import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from services.game_service import sync_match_page
|
||||
from source_config import DATA_MATCH_BASE_URL, match_url
|
||||
|
||||
BASE_MATCH_URL = "https://wfl.rfs.ru/match/"
|
||||
BASE_MATCH_URL = DATA_MATCH_BASE_URL
|
||||
|
||||
|
||||
def fetch_html(url: str) -> str:
|
||||
@@ -197,7 +198,7 @@ def parse_game_page(html: str) -> dict:
|
||||
|
||||
|
||||
def run_parser_game(match_external_id: str) -> None:
|
||||
url = f"{BASE_MATCH_URL}{str(match_external_id).strip()}"
|
||||
url = match_url(match_external_id)
|
||||
html = fetch_html(url)
|
||||
data = parse_game_page(html)
|
||||
|
||||
|
||||
@@ -3,10 +3,9 @@ from bs4 import BeautifulSoup
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from services.players_service import sync_team_roster
|
||||
from parsers.parser_sources import get_parser_source, source_absolute_url
|
||||
|
||||
|
||||
URL_TEAMS = "https://wfl.rfs.ru/tournament/1061879/teams"
|
||||
|
||||
AMPLUA_FULL = {
|
||||
"Пз.": "Полузащитник",
|
||||
"Вр.": "Вратарь",
|
||||
@@ -23,19 +22,26 @@ def fetch_html(url: str) -> str:
|
||||
return r.text
|
||||
|
||||
|
||||
def get_links(html: str) -> list[dict]:
|
||||
def get_links(html: str, source: dict) -> list[dict]:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
links: list[dict] = []
|
||||
|
||||
items = soup.find("ul", class_="teams__list").find_all("li")
|
||||
for i in items:
|
||||
href = i.find("a", class_="teams__link").get("href")
|
||||
team_external_id = href.split("team_id=")[-1].strip()
|
||||
teams_list = soup.find("ul", class_="teams__list")
|
||||
if not teams_list:
|
||||
return links
|
||||
|
||||
items = teams_list.find_all("li")
|
||||
for item in items:
|
||||
link_el = item.find("a", class_="teams__link")
|
||||
href = link_el.get("href") if link_el else ""
|
||||
if not href:
|
||||
continue
|
||||
|
||||
team_external_id = href.split("team_id=")[-1].strip() if "team_id=" in href else ""
|
||||
links.append(
|
||||
{
|
||||
"team_external_id": team_external_id,
|
||||
"url": "https://wfl.rfs.ru" + href,
|
||||
"url": source_absolute_url(source, href),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -92,13 +98,14 @@ def parse_team(html: str) -> dict:
|
||||
|
||||
full_player = name_p.get_text(strip=True) if name_p else ""
|
||||
parts = full_player.split()
|
||||
pos_short = pos_td.get_text(strip=True) if pos_td else ""
|
||||
|
||||
players.append(
|
||||
{
|
||||
"player_id": player_id or "",
|
||||
"number": number_td.get_text(strip=True) if number_td else "",
|
||||
"pos": pos_td.get_text(strip=True) if pos_td else "",
|
||||
"amplua": AMPLUA_FULL[pos_td.get_text(strip=True) if pos_td else ""],
|
||||
"pos": pos_short,
|
||||
"amplua": AMPLUA_FULL.get(pos_short, pos_short),
|
||||
"player": full_player,
|
||||
"lastname": parts[0] if len(parts) >= 1 else "",
|
||||
"name": parts[-1] if len(parts) >= 2 else "",
|
||||
@@ -144,9 +151,13 @@ def parse_team(html: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def run_parser_players() -> None:
|
||||
html = fetch_html(URL_TEAMS)
|
||||
links = get_links(html)
|
||||
def run_parser_players(source_key: str | None = None) -> None:
|
||||
source = get_parser_source(source_key)
|
||||
print(f"[parser_players] Источник: {source['title']}")
|
||||
print(f"[parser_players] URL: {source['teams_url']}")
|
||||
|
||||
html = fetch_html(source["teams_url"])
|
||||
links = get_links(html, source)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
futures = {pool.submit(fetch_html, item["url"]): item for item in links}
|
||||
@@ -169,6 +180,9 @@ def run_parser_players() -> None:
|
||||
except Exception as e:
|
||||
print(f"[parser_players] error team={item['team_external_id']}: {e}")
|
||||
|
||||
if not links:
|
||||
print("[parser_players] Команды для парсинга игроков не найдены")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_parser_players()
|
||||
|
||||
@@ -5,6 +5,7 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
from services.schedule_service import sync_matches
|
||||
from repositories.team_repository import get_team_external_id_by_name, get_team_id_by_external_id
|
||||
from parsers.parser_sources import get_parser_source
|
||||
|
||||
|
||||
MONTHS_RU = {
|
||||
@@ -24,11 +25,6 @@ MONTHS_RU = {
|
||||
|
||||
TZ = ZoneInfo("Europe/Moscow")
|
||||
|
||||
URL_SCHEDULE = (
|
||||
"https://wfl.rfs.ru/tournament/1061879/calendar?round_id=1117550&type=tours"
|
||||
)
|
||||
|
||||
SEASON = "2025/2026"
|
||||
|
||||
|
||||
def parse_russian_date(date_str: str, year: int | None = None) -> datetime:
|
||||
@@ -76,7 +72,7 @@ def safe_int(value: str | None) -> int | None:
|
||||
return int(value) if value.isdigit() else None
|
||||
|
||||
|
||||
def parse_schedule(html: str) -> list[dict]:
|
||||
def parse_schedule(html: str, season: str, source_key: str | None = None) -> list[dict]:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
matches_data: list[dict] = []
|
||||
|
||||
@@ -151,10 +147,11 @@ def parse_schedule(html: str) -> list[dict]:
|
||||
"home_score": home_score,
|
||||
"away_score": away_score,
|
||||
"tour": tour,
|
||||
"season": SEASON,
|
||||
"season": season,
|
||||
"place": place,
|
||||
"date_raw": time_site,
|
||||
"score_add": score_add,
|
||||
"source_key": source_key,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -162,12 +159,18 @@ def parse_schedule(html: str) -> list[dict]:
|
||||
return matches_data
|
||||
|
||||
|
||||
def run_parser_schedule() -> None:
|
||||
html = fetch_html(URL_SCHEDULE)
|
||||
matches_data = parse_schedule(html)
|
||||
def run_parser_schedule(source_key: str | None = None) -> None:
|
||||
source = get_parser_source(source_key)
|
||||
print(f"[parser_schedule] Источник: {source['title']}")
|
||||
print(f"[parser_schedule] URL: {source['schedule_url']}")
|
||||
|
||||
html = fetch_html(source["schedule_url"])
|
||||
matches_data = parse_schedule(html, source["season"], source["key"])
|
||||
if matches_data:
|
||||
sync_matches(matches_data)
|
||||
print(f"[parser_schedule] Matches synced: {len(matches_data)}")
|
||||
else:
|
||||
print("[parser_schedule] Матчи не найдены")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
230
parsers/parser_sources.py
Normal file
230
parsers/parser_sources.py
Normal 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")
|
||||
@@ -2,9 +2,8 @@ import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from services.standings_service import sync_standings
|
||||
from parsers.parser_sources import get_parser_source
|
||||
|
||||
URL_STANDINGS = "https://wfl.rfs.ru/tournament/1061879/tables"
|
||||
SEASON = "2025/2026"
|
||||
|
||||
|
||||
def fetch_html(url: str) -> str:
|
||||
@@ -65,16 +64,22 @@ def parse_standings(html: str) -> list[dict]:
|
||||
return standings
|
||||
|
||||
|
||||
def run_parser_standings() -> None:
|
||||
html = fetch_html(URL_STANDINGS)
|
||||
def run_parser_standings(source_key: str | None = None) -> None:
|
||||
source = get_parser_source(source_key)
|
||||
print(f"[parser_standings] Источник: {source['title']}")
|
||||
print(f"[parser_standings] URL: {source['standings_url']}")
|
||||
|
||||
html = fetch_html(source["standings_url"])
|
||||
standings_rows = parse_standings(html)
|
||||
if standings_rows:
|
||||
sync_standings(
|
||||
season=SEASON,
|
||||
season=source["season"],
|
||||
standings_rows=standings_rows,
|
||||
)
|
||||
|
||||
print(f"[parser_standings] Synced rows: {len(standings_rows)}")
|
||||
else:
|
||||
print("[parser_standings] Строки турнирной таблицы не найдены")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -3,9 +3,7 @@ from bs4 import BeautifulSoup
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from services.teams_service import sync_teams
|
||||
|
||||
|
||||
TEAMS_URL = "https://wfl.rfs.ru/tournament/1061879/teams"
|
||||
from parsers.parser_sources import get_parser_source, source_absolute_url
|
||||
|
||||
|
||||
def fetch_html(url: str) -> str:
|
||||
@@ -15,45 +13,51 @@ def fetch_html(url: str) -> str:
|
||||
return response.text
|
||||
|
||||
|
||||
def get_links(html) -> list:
|
||||
def get_links(html: str, source: dict) -> list[str]:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
links: list[dict] = []
|
||||
items = soup.find("ul", class_="teams__list").find_all("li")
|
||||
for i in items:
|
||||
links.append(
|
||||
"https://wfl.rfs.ru/team/"
|
||||
+ i.find("a", class_="teams__link").get("href").split("team_id=")[-1]
|
||||
)
|
||||
links: list[str] = []
|
||||
|
||||
teams_list = soup.find("ul", class_="teams__list")
|
||||
if not teams_list:
|
||||
return links
|
||||
|
||||
items = teams_list.find_all("li")
|
||||
for item in items:
|
||||
link_el = item.find("a", class_="teams__link")
|
||||
href = link_el.get("href") if link_el else ""
|
||||
if not href:
|
||||
continue
|
||||
|
||||
if "team_id=" in href:
|
||||
team_external_id = href.split("team_id=")[-1].strip()
|
||||
links.append(source_absolute_url(source, "/team/" + team_external_id))
|
||||
else:
|
||||
links.append(source_absolute_url(source, href))
|
||||
|
||||
return links
|
||||
|
||||
|
||||
def get_url_teams() -> list[dict]:
|
||||
html = fetch_html(TEAMS_URL)
|
||||
links = get_links(html)
|
||||
def get_url_teams(source_key: str | None = None) -> list[dict]:
|
||||
source = get_parser_source(source_key)
|
||||
html = fetch_html(source["teams_url"])
|
||||
links = get_links(html, source)
|
||||
teams_data: list[dict] = []
|
||||
|
||||
with ThreadPoolExecutor() as pool:
|
||||
responses = [
|
||||
pool.submit(
|
||||
fetch_html,
|
||||
link,
|
||||
)
|
||||
for link in links
|
||||
]
|
||||
responses = [pool.submit(fetch_html, link) for link in links]
|
||||
for result in responses:
|
||||
try:
|
||||
html = result.result()
|
||||
team_data = parse_teams_html(html)
|
||||
teams_data.append(team_data)
|
||||
except Exception as e:
|
||||
print(f"Error fetching team data: {e}")
|
||||
print(f"[parser_teams] Error fetching team data: {e}")
|
||||
|
||||
return teams_data
|
||||
|
||||
|
||||
def parse_teams_html(html: str) -> dict:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
teams_data: dict = {}
|
||||
name = soup.find("a", class_="team-promo__team-name").text.strip()
|
||||
external_id = soup.find("a", class_="team-promo__logo").get("href").split("/")[-1]
|
||||
stat_info = soup.find("ul", class_="stats-info").find_all(
|
||||
@@ -65,7 +69,7 @@ def parse_teams_html(html: str) -> dict:
|
||||
goals = stat_info[2].text.strip()
|
||||
tournaments = stat_info[3].text.strip()
|
||||
|
||||
teams_data = {
|
||||
return {
|
||||
"external_id": str(external_id),
|
||||
"name": name,
|
||||
"logo_url": logo_url,
|
||||
@@ -75,14 +79,18 @@ def parse_teams_html(html: str) -> dict:
|
||||
"tournaments": tournaments,
|
||||
}
|
||||
|
||||
return teams_data
|
||||
|
||||
def run_parser_teams(source_key: str | None = None) -> None:
|
||||
source = get_parser_source(source_key)
|
||||
print(f"[parser_teams] Источник: {source['title']}")
|
||||
print(f"[parser_teams] URL: {source['teams_url']}")
|
||||
|
||||
def run_parser_teams() -> None:
|
||||
teams_data = get_url_teams()
|
||||
teams_data = get_url_teams(source_key)
|
||||
if teams_data:
|
||||
sync_teams(teams_data)
|
||||
print(f"Teams synced: {len(teams_data)}")
|
||||
print(f"[parser_teams] Teams synced: {len(teams_data)}")
|
||||
else:
|
||||
print("[parser_teams] Команды не найдены")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -5,6 +5,24 @@ from repositories.team_repository import get_team_id_by_external_id
|
||||
from repositories.stadium_repository import get_or_create_stadium
|
||||
|
||||
|
||||
def ensure_match_source_key_column() -> None:
|
||||
"""Добавляет источник турнира для матчей, чтобы vMix выбирал правильную папку логотипов."""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
ALTER TABLE matches
|
||||
ADD COLUMN IF NOT EXISTS source_key VARCHAR(50);
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def upsert_match(
|
||||
external_id: str,
|
||||
@@ -20,6 +38,7 @@ def upsert_match(
|
||||
stadium_id: int | None = None,
|
||||
date_raw: str | None = None,
|
||||
score_add: str | None = None,
|
||||
source_key: str | None = None,
|
||||
) -> None:
|
||||
query = """
|
||||
INSERT INTO matches (
|
||||
@@ -36,10 +55,11 @@ INSERT INTO matches (
|
||||
stadium_id,
|
||||
date_raw,
|
||||
score_add,
|
||||
source_key,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
|
||||
ON CONFLICT (external_id)
|
||||
DO UPDATE SET
|
||||
home_team_id = EXCLUDED.home_team_id,
|
||||
@@ -54,6 +74,7 @@ DO UPDATE SET
|
||||
stadium_id = EXCLUDED.stadium_id,
|
||||
date_raw = EXCLUDED.date_raw,
|
||||
score_add = EXCLUDED.score_add,
|
||||
source_key = COALESCE(EXCLUDED.source_key, matches.source_key),
|
||||
updated_at = NOW();
|
||||
"""
|
||||
|
||||
@@ -76,6 +97,7 @@ DO UPDATE SET
|
||||
stadium_id,
|
||||
date_raw,
|
||||
score_add,
|
||||
source_key,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -100,6 +122,7 @@ def upsert_match_by_team_external_ids(
|
||||
stadium_id: int | None = None,
|
||||
date_raw: str | None = None,
|
||||
score_add: str | None = None,
|
||||
source_key: str | None = None,
|
||||
) -> None:
|
||||
home_team_id = get_team_id_by_external_id(home_team_external_id)
|
||||
away_team_id = get_team_id_by_external_id(away_team_external_id)
|
||||
@@ -125,6 +148,7 @@ def upsert_match_by_team_external_ids(
|
||||
stadium_id=stadium_id,
|
||||
date_raw=date_raw,
|
||||
score_add=score_add,
|
||||
source_key=source_key,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import secrets
|
||||
|
||||
from db import get_connection
|
||||
from parsers.parser_sources import build_logo_path
|
||||
|
||||
|
||||
def create_match_session(
|
||||
@@ -66,7 +67,8 @@ def get_match_session_by_token(session_token: str):
|
||||
at.id AS away_team_id,
|
||||
at.name AS away_team_name,
|
||||
at.logo_url AS away_team_logo,
|
||||
at.logo_path AS away_team_logo_path
|
||||
at.logo_path AS away_team_logo_path,
|
||||
m.source_key AS source_key
|
||||
|
||||
FROM match_sessions ms
|
||||
JOIN matches m ON m.id = ms.match_id
|
||||
@@ -80,7 +82,16 @@ def get_match_session_by_token(session_token: str):
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query, (session_token,))
|
||||
return cur.fetchone()
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
row = list(row)
|
||||
source_key = row[21] if len(row) > 21 else None
|
||||
row[16] = build_logo_path(source_key, row[16])
|
||||
row[20] = build_logo_path(source_key, row[20])
|
||||
return tuple(row)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
361
repositories/project_settings_repository.py
Normal file
361
repositories/project_settings_repository.py
Normal 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()
|
||||
@@ -1,4 +1,5 @@
|
||||
from db import get_connection
|
||||
from parsers.parser_sources import extract_logo_filename
|
||||
|
||||
|
||||
def upsert_team(
|
||||
@@ -153,7 +154,7 @@ def search_teams_for_admin(q: str = "") -> list[dict]:
|
||||
"full_name": row[2] or "",
|
||||
"short_name_3": row[3] or "",
|
||||
"city": row[4] or "",
|
||||
"logo_path": row[5] or "",
|
||||
"logo_path": extract_logo_filename(row[5]),
|
||||
"external_id": row[6] or "",
|
||||
}
|
||||
for row in rows
|
||||
@@ -192,7 +193,7 @@ def get_team_by_id(team_id: int) -> dict | None:
|
||||
"full_name": row[2] or "",
|
||||
"short_name_3": row[3] or "",
|
||||
"city": row[4] or "",
|
||||
"logo_path": row[5] or "",
|
||||
"logo_path": extract_logo_filename(row[5]),
|
||||
"external_id": row[6] or "",
|
||||
}
|
||||
finally:
|
||||
@@ -227,7 +228,7 @@ def update_team_admin(
|
||||
full_name.strip(),
|
||||
short_name_3.strip().upper(),
|
||||
city.strip(),
|
||||
logo_path.strip(),
|
||||
extract_logo_filename(logo_path),
|
||||
external_id.strip(),
|
||||
team_id,
|
||||
),
|
||||
|
||||
@@ -12,6 +12,8 @@ from parsers.parser_standings import run_parser_standings
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from source_config import match_url
|
||||
|
||||
|
||||
TZ = ZoneInfo("Europe/Moscow")
|
||||
|
||||
@@ -202,7 +204,7 @@ def parse_score(value: str) -> int | None:
|
||||
|
||||
|
||||
def fetch_match_live_data(match_id: int) -> dict:
|
||||
html = fetch_html(f"https://wfl.rfs.ru/match/{match_id}")
|
||||
html = fetch_html(match_url(match_id))
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
|
||||
score_box = soup.find("div", class_="score__container")
|
||||
|
||||
148
services/env_settings_service.py
Normal file
148
services/env_settings_service.py
Normal 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
|
||||
142
services/project_settings_service.py
Normal file
142
services/project_settings_service.py
Normal 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
|
||||
@@ -16,4 +16,5 @@ def sync_matches(matches_data: list[dict]) -> None:
|
||||
place=match.get("place"),
|
||||
date_raw=match.get("date_raw"),
|
||||
score_add=match.get("score_add"),
|
||||
source_key=match.get("source_key"),
|
||||
)
|
||||
@@ -1,27 +1,44 @@
|
||||
# services/vmix_json_service.py
|
||||
from db import get_connection
|
||||
from repositories.match_lineup_repository import get_match_lineup_for_vmix
|
||||
from parsers.parser_sources import build_empty_photo_path, build_logo_path, build_logo_variant_path, build_photo_path
|
||||
|
||||
|
||||
PHOTO_BASE_PATH = r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo"
|
||||
EMPTY_PHOTO_PATH = PHOTO_BASE_PATH + r"\EMPTY.png"
|
||||
DEFAULT_PHOTO_BASE_PATH = r"D:\Графика\ФУТБОЛ\Женская Суперлига 2026\Photo"
|
||||
EMPTY_PHOTO_PATH = DEFAULT_PHOTO_BASE_PATH + r"\EMPTY.png"
|
||||
|
||||
|
||||
def build_generated_player_photo_path(team_name: str, last_name: str, first_name: str) -> str:
|
||||
return (
|
||||
PHOTO_BASE_PATH
|
||||
+ "\\"
|
||||
+ str(team_name or "")
|
||||
def _session_source_key(session_row) -> str | None:
|
||||
try:
|
||||
value = session_row[21]
|
||||
except Exception:
|
||||
value = None
|
||||
return str(value).strip() if value else None
|
||||
|
||||
|
||||
def build_generated_player_photo_path(
|
||||
team_name: str,
|
||||
last_name: str,
|
||||
first_name: str,
|
||||
source_key: str | None = None,
|
||||
) -> str:
|
||||
relative_file = (
|
||||
str(team_name or "").strip()
|
||||
+ "\\"
|
||||
+ (str(last_name or "") + " " + str(first_name or "")).strip()
|
||||
+ ".png"
|
||||
)
|
||||
return build_photo_path(source_key, relative_file)
|
||||
|
||||
|
||||
def resolve_player_photo(photo_enabled: bool, generated_photo: str) -> str:
|
||||
def resolve_player_photo(
|
||||
photo_enabled: bool,
|
||||
generated_photo: str,
|
||||
source_key: str | None = None,
|
||||
) -> str:
|
||||
if photo_enabled:
|
||||
return generated_photo
|
||||
return EMPTY_PHOTO_PATH
|
||||
return build_empty_photo_path(source_key) or EMPTY_PHOTO_PATH
|
||||
|
||||
|
||||
def _normalize_text(value) -> str:
|
||||
@@ -208,6 +225,7 @@ def resolve_player_photo_for_json(
|
||||
player: dict,
|
||||
generated_photo: str,
|
||||
fallback_team_id=None,
|
||||
source_key: str | None = None,
|
||||
) -> str:
|
||||
state = _find_player_photo_state(
|
||||
player_id=player.get("player_id") or player.get("id"),
|
||||
@@ -220,16 +238,24 @@ def resolve_player_photo_for_json(
|
||||
)
|
||||
|
||||
if state is not None:
|
||||
_photo, photo_enabled = state
|
||||
return resolve_player_photo(photo_enabled=photo_enabled, generated_photo=generated_photo)
|
||||
photo_value, photo_enabled = state
|
||||
resolved_photo = build_photo_path(source_key, photo_value) if photo_value else generated_photo
|
||||
return resolve_player_photo(
|
||||
photo_enabled=photo_enabled,
|
||||
generated_photo=resolved_photo,
|
||||
source_key=source_key,
|
||||
)
|
||||
|
||||
photo_value = player.get("photo") or ""
|
||||
resolved_photo = build_photo_path(source_key, photo_value) if photo_value else generated_photo
|
||||
return resolve_player_photo(
|
||||
photo_enabled=player.get("photo_enabled"),
|
||||
generated_photo=generated_photo,
|
||||
generated_photo=resolved_photo,
|
||||
source_key=source_key,
|
||||
)
|
||||
|
||||
|
||||
def build_lineup_json(match_id, home_team_id, away_team_id, name, team_a_name, team_b_name):
|
||||
def build_lineup_json(match_id, home_team_id, away_team_id, name, team_a_name, team_b_name, source_key=None):
|
||||
lineups = get_match_lineup_for_vmix(
|
||||
match_id=match_id,
|
||||
home_team_id=home_team_id,
|
||||
@@ -274,8 +300,10 @@ def build_lineup_json(match_id, home_team_id, away_team_id, name, team_a_name, t
|
||||
team_name=team_a_name if "home" in name else team_b_name,
|
||||
last_name=p.get("last_name", ""),
|
||||
first_name=p.get("first_name", ""),
|
||||
source_key=source_key,
|
||||
),
|
||||
fallback_team_id=fallback_team_id,
|
||||
source_key=source_key,
|
||||
),
|
||||
}
|
||||
)
|
||||
@@ -305,43 +333,16 @@ def get_vmix_match_info_by_token(session_token: str):
|
||||
|
||||
m.tour,
|
||||
ht.logo_path AS home_logo,
|
||||
REPLACE(at.logo_path, 'HOME', 'AWAY') AS away_logo,
|
||||
at.logo_path AS away_logo,
|
||||
ref1.referee_name AS referee1,
|
||||
ref2.referee_name AS referee2,
|
||||
ref3.referee_name AS referee3,
|
||||
ref4.referee_name AS referee4,
|
||||
|
||||
CASE
|
||||
WHEN ht.name ILIKE '%%динамо%%'
|
||||
THEN REPLACE(REPLACE(ht.logo_path, 'HOME\\', ''), 'Динамо', 'Динамо_Белый')
|
||||
WHEN ht.name ILIKE '%%зенит%%'
|
||||
THEN REPLACE(REPLACE(ht.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Белый')
|
||||
ELSE REPLACE(ht.logo_path, 'HOME\\', '')
|
||||
END AS home_logo1,
|
||||
|
||||
CASE
|
||||
WHEN at.name ILIKE '%%динамо%%'
|
||||
THEN REPLACE(REPLACE(at.logo_path, 'HOME\\', ''), 'Динамо', 'Динамо_Белый')
|
||||
WHEN at.name ILIKE '%%зенит%%'
|
||||
THEN REPLACE(REPLACE(at.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Белый')
|
||||
ELSE REPLACE(at.logo_path, 'HOME\\', '')
|
||||
END AS away_logo1,
|
||||
|
||||
CASE
|
||||
WHEN ht.name ILIKE '%%динамо%%'
|
||||
THEN REPLACE(REPLACE(ht.logo_path, 'HOME\\', ''), 'Динамо', 'Динамо_Синий')
|
||||
WHEN ht.name ILIKE '%%зенит%%'
|
||||
THEN REPLACE(REPLACE(ht.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Синий')
|
||||
ELSE REPLACE(ht.logo_path, 'HOME\\', '')
|
||||
END AS home_logo2,
|
||||
|
||||
CASE
|
||||
WHEN at.name ILIKE '%%динамо%%'
|
||||
THEN REPLACE(REPLACE(at.logo_path, 'HOME\\', ''), 'Динамо', 'Динамо_Синий')
|
||||
WHEN at.name ILIKE '%%зенит%%'
|
||||
THEN REPLACE(REPLACE(at.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Синий')
|
||||
ELSE REPLACE(at.logo_path, 'HOME\\', '')
|
||||
END AS away_logo2,
|
||||
ht.logo_path AS home_logo1,
|
||||
at.logo_path AS away_logo1,
|
||||
ht.logo_path AS home_logo2,
|
||||
at.logo_path AS away_logo2,
|
||||
|
||||
ht.city AS home_city,
|
||||
at.city AS away_city,
|
||||
@@ -350,7 +351,8 @@ def get_vmix_match_info_by_token(session_token: str):
|
||||
c1.amplua AS coach_amplua1,
|
||||
|
||||
TRIM(COALESCE(c2.name, '') || ' ' || COALESCE(c2.lastname, '')) AS coach_name2,
|
||||
c2.amplua AS coach_amplua2
|
||||
c2.amplua AS coach_amplua2,
|
||||
m.source_key AS source_key
|
||||
|
||||
FROM match_sessions ms
|
||||
JOIN matches m ON m.id = ms.match_id
|
||||
@@ -378,23 +380,33 @@ def get_vmix_match_info_by_token(session_token: str):
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query, (session_token,))
|
||||
return cur.fetchone()
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
row = list(row)
|
||||
source_key = row[30] if len(row) > 30 else None
|
||||
row[14] = build_logo_path(source_key, row[14])
|
||||
row[15] = build_logo_path(source_key, row[15])
|
||||
row[20] = build_logo_variant_path(source_key, row[20], "white")
|
||||
row[21] = build_logo_variant_path(source_key, row[21], "white")
|
||||
row[22] = build_logo_variant_path(source_key, row[22], "blue")
|
||||
row[23] = build_logo_variant_path(source_key, row[23], "blue")
|
||||
return tuple(row)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_vmix_standings(session_token: str):
|
||||
def get_vmix_standings(session_row):
|
||||
source_key = _session_source_key(session_row)
|
||||
season = session_row[11] if len(session_row) > 11 else None
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
s.position,
|
||||
t.full_name,
|
||||
CASE
|
||||
WHEN t.full_name ILIKE '%%динамо%%'
|
||||
THEN REPLACE(REPLACE(t.logo_path, 'HOME\\', ''), 'Динамо', 'Динамо_Белый')
|
||||
WHEN t.full_name ILIKE '%%зенит%%'
|
||||
THEN REPLACE(REPLACE(t.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Белый')
|
||||
ELSE REPLACE(t.logo_path, 'HOME\\', '')
|
||||
END AS logo,
|
||||
COALESCE(t.full_name, t.name) AS team_name,
|
||||
t.logo_path AS logo,
|
||||
s.played,
|
||||
s.wins,
|
||||
s.losses,
|
||||
@@ -404,35 +416,36 @@ def get_vmix_standings(session_token: str):
|
||||
s.team_id
|
||||
FROM standings s
|
||||
LEFT JOIN teams t ON s.team_id = t.id
|
||||
WHERE (%s IS NULL OR s.season = %s)
|
||||
ORDER BY s.position
|
||||
"""
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query, (session_token,))
|
||||
return cur.fetchall()
|
||||
cur.execute(query, (season, season))
|
||||
rows = cur.fetchall()
|
||||
|
||||
result = []
|
||||
for row in rows:
|
||||
row = list(row)
|
||||
row[2] = build_logo_variant_path(source_key, row[2], "white")
|
||||
result.append(tuple(row))
|
||||
return result
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_vmix_schedule(session_token: str):
|
||||
def get_vmix_schedule(session_row):
|
||||
source_key = _session_source_key(session_row)
|
||||
tour = session_row[10] if len(session_row) > 10 else None
|
||||
season = session_row[11] if len(session_row) > 11 else None
|
||||
source_filter = source_key or "SUPERLEAGUE"
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
CASE
|
||||
WHEN t1.full_name ILIKE '%%динамо%%'
|
||||
THEN REPLACE(REPLACE(t1.logo_path, 'HOME\\', ''), 'Динамо', 'Динамо_Белый')
|
||||
WHEN t1.full_name ILIKE '%%зенит%%'
|
||||
THEN REPLACE(REPLACE(t1.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Белый')
|
||||
ELSE REPLACE(t1.logo_path, 'HOME\\', '')
|
||||
END AS logo1,
|
||||
CASE
|
||||
WHEN t2.full_name ILIKE '%%динамо%%'
|
||||
THEN REPLACE(REPLACE(t2.logo_path, 'HOME\\', ''), 'Динамо', 'Динамо_Белый')
|
||||
WHEN t2.full_name ILIKE '%%зенит%%'
|
||||
THEN REPLACE(REPLACE(t2.logo_path, 'HOME\\', ''), 'Зенит', 'Зенит_Белый')
|
||||
ELSE REPLACE(t2.logo_path, 'HOME\\', '')
|
||||
END AS logo2,
|
||||
t1.logo_path AS logo1,
|
||||
t2.logo_path AS logo2,
|
||||
m.home_score,
|
||||
m.away_score,
|
||||
m.match_date,
|
||||
@@ -443,14 +456,24 @@ def get_vmix_schedule(session_token: str):
|
||||
LEFT JOIN teams t1 ON m.home_team_id = t1.id
|
||||
LEFT JOIN teams t2 ON m.away_team_id = t2.id
|
||||
WHERE m.tour = %s
|
||||
AND (%s IS NULL OR m.season = %s)
|
||||
AND COALESCE(m.source_key, %s) = %s
|
||||
ORDER BY m.match_date, m.id
|
||||
"""
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query, (session_token[10],))
|
||||
return cur.fetchall()
|
||||
cur.execute(query, (tour, season, season, source_filter, source_filter))
|
||||
rows = cur.fetchall()
|
||||
|
||||
result = []
|
||||
for row in rows:
|
||||
row = list(row)
|
||||
row[0] = build_logo_variant_path(source_key, row[0], "white")
|
||||
row[1] = build_logo_variant_path(source_key, row[1], "white")
|
||||
result.append(tuple(row))
|
||||
return result
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
32
source_config.py
Normal file
32
source_config.py
Normal 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)
|
||||
4
sql/003_matches_source_key.sql
Normal file
4
sql/003_matches_source_key.sql
Normal 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);
|
||||
79
sql/004_project_settings.sql
Normal file
79
sql/004_project_settings.sql
Normal 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;
|
||||
14
sql/005_parser_sources_photo_base_path.sql
Normal file
14
sql/005_parser_sources_photo_base_path.sql
Normal 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) = '');
|
||||
@@ -1260,14 +1260,28 @@ function closeGoalEditor() {
|
||||
|
||||
|
||||
function getVmixTeamLogo(teamName) {
|
||||
const matchData = window.MATCH_DATA || {};
|
||||
const teamNames = matchData.teamNames || {};
|
||||
const vmixTeamLogos = matchData.vmixTeamLogos || {};
|
||||
const normalizedTeamName = String(teamName || "").trim();
|
||||
|
||||
if (normalizedTeamName && normalizedTeamName === String(teamNames.home || "").trim()) {
|
||||
return vmixTeamLogos.home || "";
|
||||
}
|
||||
|
||||
if (normalizedTeamName && normalizedTeamName === String(teamNames.away || "").trim()) {
|
||||
return vmixTeamLogos.away || "";
|
||||
}
|
||||
|
||||
// Fallback для старых страниц без MATCH_DATA.
|
||||
const basePath = "D:\\Графика\\ФУТБОЛ\\Женская Суперлига 2026\\Teams Logos";
|
||||
let clean = String(teamName || "").replaceAll("«", "").replaceAll("»", "").trim();
|
||||
let clean = normalizedTeamName.replaceAll("«", "").replaceAll("»", "").trim();
|
||||
|
||||
if (clean === "Зенит" || clean === "Динамо") {
|
||||
clean += "_Синий";
|
||||
}
|
||||
|
||||
return `${basePath}\\${clean}.png`;
|
||||
return clean ? `${basePath}\\${clean}.png` : "";
|
||||
}
|
||||
|
||||
function getScoreSum() {
|
||||
|
||||
@@ -107,6 +107,82 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.parser-import-card {
|
||||
width: 100%;
|
||||
padding: 18px;
|
||||
border: 1px solid rgba(0, 255, 136, 0.18);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
.parser-import-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.parser-import-subtitle {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.parser-import-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 360px) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.parser-checkboxes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.parser-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: #0f141d;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.parser-checkbox input {
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.parser-submit-row {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.parser-mode-note {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.parser-import-grid,
|
||||
.parser-checkboxes {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.log-block {
|
||||
margin-top: 20px;
|
||||
border: 1px solid var(--border);
|
||||
@@ -151,7 +227,8 @@
|
||||
}
|
||||
|
||||
.account-modal,
|
||||
.update-modal {
|
||||
.update-modal,
|
||||
.settings-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
@@ -163,12 +240,14 @@
|
||||
}
|
||||
|
||||
.account-modal.active,
|
||||
.update-modal.active {
|
||||
.update-modal.active,
|
||||
.settings-modal.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.account-modal-card,
|
||||
.update-modal-card {
|
||||
.update-modal-card,
|
||||
.settings-modal-card {
|
||||
width: min(100%, 460px);
|
||||
background: rgba(12, 17, 26, 0.98);
|
||||
border: 1px solid rgba(0, 255, 136, 0.28);
|
||||
@@ -177,21 +256,24 @@
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.update-modal-card {
|
||||
.update-modal-card,
|
||||
.settings-modal-card {
|
||||
width: min(100%, 760px);
|
||||
max-height: 92vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.account-modal-title,
|
||||
.update-modal-title {
|
||||
.update-modal-title,
|
||||
.settings-modal-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.account-modal-subtitle,
|
||||
.update-modal-subtitle {
|
||||
.update-modal-subtitle,
|
||||
.settings-modal-subtitle {
|
||||
color: var(--muted);
|
||||
margin-bottom: 18px;
|
||||
line-height: 1.5;
|
||||
@@ -202,6 +284,46 @@
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.env-groups {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.env-group {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
.env-group-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.env-group-subtitle {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.env-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.env-field-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.env-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
@@ -407,24 +529,66 @@
|
||||
</div>
|
||||
|
||||
<div class="parser-actions">
|
||||
<form method="post" action="/admin/db/run-parser" class="action-form" data-parser-title="Игроки" data-parser-status="Сбор и сохранение базы игроков...">
|
||||
<input type="hidden" name="parser_name" value="players">
|
||||
<button type="submit" class="action-btn">🗄️ Заграбить игроков</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/db/run-parser" class="action-form parser-import-card" data-parser-title="Выбранные данные" data-parser-status="Загрузка выбранных данных из выбранного турнира...">
|
||||
<div class="parser-import-title">Импорт данных</div>
|
||||
<div class="parser-import-subtitle">
|
||||
Выберите источник турнира и отметьте, какие данные нужно заграбить. Существующие команды, игроки и матчи будут обновлены через UPSERT, без полной очистки таблиц.
|
||||
</div>
|
||||
|
||||
<form method="post" action="/admin/db/run-parser" class="action-form" data-parser-title="Расписание" data-parser-status="Загрузка расписания матчей с сайта...">
|
||||
<input type="hidden" name="parser_name" value="schedule">
|
||||
<button type="submit" class="action-btn">🗄️ Заграбить расписание</button>
|
||||
</form>
|
||||
<div class="parser-import-grid">
|
||||
<div>
|
||||
<label class="field-label" for="parserSource">Источник / турнир</label>
|
||||
{% 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="Обновление турнирной таблицы...">
|
||||
<input type="hidden" name="parser_name" value="standings">
|
||||
<button type="submit" class="action-btn">🗄️ Заграбить турнирку</button>
|
||||
<div>
|
||||
<label class="field-label">Что парсить</label>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="account-actions">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -458,6 +622,21 @@
|
||||
</div>
|
||||
{% 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 %}
|
||||
<div class="log-block {% if update_success %}success{% else %}error{% endif %}">
|
||||
<div class="log-title">Добавление обновления</div>
|
||||
@@ -510,6 +689,58 @@
|
||||
</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 class="update-modal-card">
|
||||
<div class="update-modal-title">Добавить обновление</div>
|
||||
@@ -617,9 +848,15 @@
|
||||
const canvas = document.getElementById("matrixCanvas");
|
||||
const ctx = canvas ? canvas.getContext("2d") : null;
|
||||
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 closeCreateAccountBtn = document.getElementById("closeCreateAccountBtn");
|
||||
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 closeAddUpdateBtn = document.getElementById("closeAddUpdateBtn");
|
||||
const updateModal = document.getElementById("updateModal");
|
||||
@@ -695,6 +932,18 @@
|
||||
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() {
|
||||
if (!updateModal) return;
|
||||
if (updateDate && !updateDate.value) {
|
||||
@@ -742,6 +991,17 @@
|
||||
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) => {
|
||||
const button = event.target.closest(".format-btn");
|
||||
if (!button) return;
|
||||
@@ -757,6 +1017,9 @@
|
||||
insertSnippet(textarea, snippet);
|
||||
});
|
||||
|
||||
updateSelectedSourcePaths();
|
||||
parserSourceSelect?.addEventListener("change", updateSelectedSourcePaths);
|
||||
|
||||
parserForms.forEach((form) => {
|
||||
form.addEventListener("submit", () => {
|
||||
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) {
|
||||
openAddUpdateBtn.addEventListener("click", openUpdateModal);
|
||||
}
|
||||
@@ -809,6 +1088,7 @@
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
closeAccountModal();
|
||||
closeSettingsModal();
|
||||
closeUpdateModal();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -229,13 +229,13 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group full">
|
||||
<label>Фото игрока</label>
|
||||
<input type="text" name="photo" value="{{ player.photo }}">
|
||||
<label>Файл фото игрока</label>
|
||||
<input type="text" name="photo" value="{{ player.photo }}" placeholder="Например: Динамо\Иванов Иван.png или Иванов Иван.png">
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" name="photo_enabled" value="true" {% if player.photo_enabled %}checked{% endif %}>
|
||||
Использовать фото в JSON/vMix
|
||||
</label>
|
||||
<div class="hint">Если галочка включена, путь к фото в JSON генерируется по старой схеме: Photo\Команда\Фамилия Имя.png. Если выключена — отдается EMPTY.png.</div>
|
||||
<div class="hint">Если поле пустое, путь генерируется автоматически: папка фотографий источника + Команда\Фамилия Имя.png. Если поле заполнено, можно указать имя файла или относительный путь. Если галочка выключена — отдается EMPTY.png из папки выбранного источника.</div>
|
||||
{% if player.photo %}
|
||||
<div class="media-preview-box">
|
||||
<img src="{{ player.photo }}" alt="{{ player.full_name }}" class="media-preview-image">
|
||||
|
||||
@@ -27,19 +27,6 @@
|
||||
background: var(--panel-2);
|
||||
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; }
|
||||
.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; }
|
||||
@@ -80,14 +67,10 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group full">
|
||||
<label>Путь к логотипу</label>
|
||||
<input type="text" name="logo_path" value="{{ team.logo_path }}">
|
||||
<div class="logo-box">
|
||||
{% if team.logo_path %}
|
||||
<img src="{{ team.logo_path }}" alt="{{ team.name }}" class="logo-preview">
|
||||
{% else %}
|
||||
Логотип не задан.
|
||||
{% endif %}
|
||||
<label>Файл логотипа</label>
|
||||
<input type="text" name="logo_path" value="{{ team.logo_path }}" placeholder="Например: Динамо_Синий.png">
|
||||
<div class="small-meta">
|
||||
В команде хранится только имя файла. Полный путь подставляется автоматически по источнику выбранного матча.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,13 +26,6 @@
|
||||
tr:last-child td { border-bottom: none; }
|
||||
.id-col { width: 70px; }
|
||||
.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); }
|
||||
</style>
|
||||
</head>
|
||||
@@ -57,7 +50,7 @@
|
||||
<th>Полное название</th>
|
||||
<th>Город</th>
|
||||
<th>3 буквы</th>
|
||||
<th>Логотип</th>
|
||||
<th>Файл логотипа</th>
|
||||
<th>External ID</th>
|
||||
<th class="action-col"></th>
|
||||
</tr>
|
||||
@@ -70,13 +63,7 @@
|
||||
<td>{{ t.full_name or "—" }}</td>
|
||||
<td>{{ t.city or "—" }}</td>
|
||||
<td>{{ t.short_name_3 or "—" }}</td>
|
||||
<td>
|
||||
{% if t.logo_path %}
|
||||
<img src="{{ t.logo_path }}" alt="{{ t.name }}" class="logo-preview">
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ t.logo_path or "—" }}</td>
|
||||
<td>{{ t.external_id }}</td>
|
||||
<td class="action-col">
|
||||
<a href="/admin/db/teams/{{ t.id }}/edit" class="btn btn-secondary">Редактировать</a>
|
||||
|
||||
@@ -13,6 +13,27 @@ SYNO_URL = os.getenv("SYNO_URL")
|
||||
SYNO_USERNAME = os.getenv("SYNO_USERNAME")
|
||||
SYNO_PASSWORD = os.getenv("SYNO_PASSWORD")
|
||||
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():
|
||||
@@ -145,13 +166,18 @@ def build_vmix_project_bytes(
|
||||
session_token: str,
|
||||
match_id: str | int | None = None,
|
||||
operator_login: str | None = None,
|
||||
source_key: str | None = None,
|
||||
) -> 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(
|
||||
user=SYNO_USERNAME,
|
||||
password=SYNO_PASSWORD,
|
||||
nas_ip=SYNO_URL,
|
||||
nas_port="443",
|
||||
path=SYNO_PATH_VMIX,
|
||||
path=vmix_preset_path,
|
||||
)
|
||||
|
||||
edited_vmix = change_vmix_datasource_urls(
|
||||
|
||||
83
wfl.sql
83
wfl.sql
@@ -55,12 +55,14 @@ CREATE TABLE matches (
|
||||
away_score INTEGER,
|
||||
tour VARCHAR(100),
|
||||
season VARCHAR(50),
|
||||
source_key VARCHAR(50),
|
||||
created_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_season ON matches(season);
|
||||
CREATE INDEX idx_matches_source_key ON matches(source_key);
|
||||
CREATE INDEX idx_matches_date ON matches(match_date);
|
||||
|
||||
|
||||
@@ -85,3 +87,84 @@ CREATE TABLE standings (
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user