From c759f34f0fbeae65dfea4afc07554b15b4ba8fec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=AE=D1=80=D0=B8=D0=B9=20=D0=A7=D0=B5=D1=80=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=BA=D0=BE?= Date: Mon, 10 Aug 2026 10:49:32 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D0=B5=D1=89=D0=B5=20=D0=BA=D0=B0=D0=BF=D0=B8=D1=82=D0=BE=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 24 ++-------- parsers/parser_game.py | 29 ++++++++++- services/game_service.py | 101 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 130 insertions(+), 24 deletions(-) diff --git a/app.py b/app.py index a060a7a..a7110c4 100644 --- a/app.py +++ b/app.py @@ -836,26 +836,10 @@ def load_match_data(request: Request, session_token: str): match_external_id = session_row[8] try: - from db import get_connection - - # Сначала очищаем ручные данные матча, - # чтобы парсер потом записал свежие составы и тренеров - conn = get_connection() - try: - with conn.cursor() as cur: - cur.execute( - "DELETE FROM match_lineup_players WHERE match_id = %s", - (match_id,), - ) - cur.execute( - "DELETE FROM match_coaches WHERE match_id = %s", - (match_id,), - ) - conn.commit() - finally: - conn.close() - - # Потом загружаем свежие данные с сайта + # Очистка и замена матчевых данных выполняется внутри sync_match_page(). + # Важно не удалять ручной состав здесь заранее: сервис сначала должен + # увидеть ранее выбранного капитана и, если сайт забыл его указать, + # сохранить этот выбор при повторной загрузке. run_parser_game(str(match_external_id)) except Exception as e: diff --git a/parsers/parser_game.py b/parsers/parser_game.py index 7fc198d..b70aa46 100644 --- a/parsers/parser_game.py +++ b/parsers/parser_game.py @@ -21,12 +21,37 @@ def extract_player_id_from_href(href: str) -> str: def detect_captain(item) -> bool: + """Определяет капитана по текстовой метке или отдельному HTML-маркеру сайта. + + На странице протокола капитан может быть обозначен как ``(К)`` / ``(C)``, + а в некоторых версиях вёрстки — отдельным элементом/иконкой с captain в + class, title, aria-label или data-атрибуте. Если сайт вообще не указал + капитана, функция корректно возвращает False. + """ if not item: return False - text = item.get_text(" ", strip=True).lower() + text = item.get_text(" ", strip=True).lower().replace("ё", "е") + if any(marker in text for marker in ("(к)", "(c)", "капитан", "captain")): + return True - return any(x in text for x in ["(к)", "(c)"]) + # Поддержка отдельной иконки/элемента капитана, если буква не входит + # в видимый текст строки игрока. Не привязываемся к одной версии вёрстки. + for node in [item, *item.find_all(True)]: + classes = " ".join(node.get("class", [])).lower() + attrs_text = " ".join( + str(node.get(attr) or "") + for attr in ("title", "aria-label", "data-title", "data-role", "data-captain") + ).lower().replace("ё", "е") + + if "captain" in classes or "капитан" in classes: + return True + if "captain" in attrs_text or "капитан" in attrs_text: + return True + if str(node.get("data-captain") or "").strip().lower() in {"1", "true", "yes"}: + return True + + return False def parse_starting_teams(soup: BeautifulSoup) -> tuple[list[dict], list[dict]]: home_starting = [] diff --git a/services/game_service.py b/services/game_service.py index b11f2ca..bed459b 100644 --- a/services/game_service.py +++ b/services/game_service.py @@ -15,7 +15,10 @@ from repositories.coach_repository import ( create_coach_from_lineup, ) from repositories.referee_repository import get_referee_id_by_name, upsert_referee -from repositories.match_lineup_repository import replace_match_lineups, save_match_lineup_for_editor +from repositories.match_lineup_repository import ( + replace_match_lineups, + get_match_lineup_for_editor, +) from repositories.match_coach_repository import replace_match_coaches from repositories.match_referee_repository import replace_match_referees @@ -35,6 +38,31 @@ def sync_match_page( raise ValueError(f"Match not found by external_id: {match_external_id}") match_id, _, home_team_id, away_team_id = match_row + + # Сохраняем текущий выбор капитана ДО очистки данных матча. Это важно + # при повторной загрузке с сайта: на сайте капитана иногда не указывают, + # и в таком случае нельзя молча терять уже сделанный оператором выбор. + previous_lineup = get_match_lineup_for_editor( + match_id=match_id, + home_team_id=home_team_id, + away_team_id=away_team_id, + ) + + def previous_captain(side: str) -> dict | None: + rows = previous_lineup.get(f"{side}_starting", []) or [] + for player in rows: + if bool(player.get("is_captain")): + return { + "player_id": player.get("player_id"), + "number": str(player.get("number") or "").strip(), + } + return None + + previous_captains = { + "home": previous_captain("home"), + "away": previous_captain("away"), + } + clear_match_squad_data(match_id) created_players_count = 0 @@ -117,7 +145,10 @@ def sync_match_page( "number": player.get("number"), "position": player.get("position"), "position_full": player_position, - "is_captain": bool(player.get("is_captain")), + # Капитаном может быть только игрок основного состава. + # Если сайт по ошибке пометил игрока запаса, не переносим + # такой флаг в матчевые данные. + "is_captain": bool(player.get("is_captain")) and lineup_type == "starting", "lineup_type": lineup_type, "source": "parser", } @@ -128,6 +159,72 @@ def sync_match_page( append_players(home_bench, home_team_id, "bench") append_players(away_bench, away_team_id, "bench") + def restore_previous_captain_if_missing(side: str, team_id: int) -> bool: + """Возвращает старого капитана только когда сайт не указал нового. + + Восстановление разрешено исключительно для игрока, который всё ещё + присутствует в основном составе. Если прежний капитан отсутствует или + ушёл в запас, капитан остаётся не выбран — веб-интерфейс покажет + обязательное предупреждение оператору. + """ + starting_rows = [ + row for row in lineup_rows + if row.get("team_id") == team_id and row.get("lineup_type") == "starting" + ] + + imported_captains = [row for row in starting_rows if bool(row.get("is_captain"))] + + # Ровно один капитан с сайта — корректные данные, они имеют приоритет. + if len(imported_captains) == 1: + return False + + # Если сайт по ошибке передал больше одного капитана, не выбираем + # случайного игрока. Сбрасываем конфликт и используем тот же безопасный + # fallback, что и при полностью отсутствующем капитане. + if len(imported_captains) > 1: + for row in imported_captains: + row["is_captain"] = False + + old_captain = previous_captains.get(side) + if not old_captain: + return False + + old_player_id = old_captain.get("player_id") + old_number = str(old_captain.get("number") or "").strip() + + # Сначала точное совпадение по player_id. + if old_player_id is not None: + for row in starting_rows: + if row.get("player_id") is not None and str(row.get("player_id")) == str(old_player_id): + row["is_captain"] = True + return True + + # Fallback для старых/неполных данных — по игровому номеру. + if old_number: + matches = [ + row for row in starting_rows + if str(row.get("number") or "").strip() == old_number + ] + if len(matches) == 1: + matches[0]["is_captain"] = True + return True + + return False + + restored_home_captain = restore_previous_captain_if_missing("home", home_team_id) + restored_away_captain = restore_previous_captain_if_missing("away", away_team_id) + + if restored_home_captain or restored_away_captain: + restored = [] + if restored_home_captain: + restored.append("home") + if restored_away_captain: + restored.append("away") + print( + f"[parser_game] website captain missing for match={match_external_id}; " + f"preserved previous captain for: {', '.join(restored)}" + ) + coach_rows = [] def append_coaches(coaches: list[dict], team_id: int, side: str) -> None: