from __future__ import annotations import hashlib import re import unicodedata from dataclasses import dataclass, field from datetime import date, datetime from typing import Any from xml.etree import ElementTree as ET def local_name(tag: str) -> str: return str(tag or "").split("}")[-1].split(":")[-1] def normalise_key(value: str) -> str: return re.sub(r"[^a-z0-9]+", "", local_name(value).lower()) def text_value(node: ET.Element | None) -> str: if node is None: return "" return " ".join(part.strip() for part in node.itertext() if part.strip()).strip() def first_value(values: dict[str, str], aliases: tuple[str, ...]) -> str: for alias in aliases: value = values.get(normalise_key(alias), "").strip() if value: return value return "" def safe_int(value: Any, default: int = 0) -> int: match = re.search(r"-?\d+", str(value or "")) try: return int(match.group(0)) if match else default except (TypeError, ValueError): return default _RU_MONTHS = { "январь": 1, "января": 1, "февраль": 2, "февраля": 2, "март": 3, "марта": 3, "апрель": 4, "апреля": 4, "май": 5, "мая": 5, "июнь": 6, "июня": 6, "июль": 7, "июля": 7, "август": 8, "августа": 8, "сентябрь": 9, "сентября": 9, "октябрь": 10, "октября": 10, "ноябрь": 11, "ноября": 11, "декабрь": 12, "декабря": 12, } _EN_MONTHS = { "january": 1, "jan": 1, "february": 2, "feb": 2, "march": 3, "mar": 3, "april": 4, "apr": 4, "may": 5, "june": 6, "jun": 6, "july": 7, "jul": 7, "august": 8, "aug": 8, "september": 9, "sep": 9, "sept": 9, "october": 10, "oct": 10, "november": 11, "nov": 11, "december": 12, "dec": 12, } def parse_date_value(value: str) -> date | None: # KHL/Stat2TV sometimes uses non-breaking/thin spaces and other Unicode # punctuation in localized dates. Normalize that input first so values like # "16 августа 2026, Вс" are parsed consistently on every OS/locale. value = unicodedata.normalize("NFKC", str(value or "")) value = value.replace("\u00a0", " ").replace("\u202f", " ").strip() value = re.sub(r"\s+", " ", value) if not value or value == "0000-00-00": return None iso_match = re.search(r"(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})", value) if iso_match: try: return date( int(iso_match.group(1)), int(iso_match.group(2)), int(iso_match.group(3)), ) except ValueError: pass numeric_match = re.search(r"(\d{1,2})[./-](\d{1,2})[./-](\d{4})", value) if numeric_match: try: return date( int(numeric_match.group(3)), int(numeric_match.group(2)), int(numeric_match.group(1)), ) except ValueError: pass # Stat2TV/KHL detail cards may return a localized human-readable value, # e.g. "16 августа 2026, Вс 13:00:00". Parse it without relying on # the PostgreSQL/OS locale so game_date always remains a real DATE. word_month_match = re.search( r"\b(\d{1,2})\s+([A-Za-zА-Яа-яЁё]+)\s+(\d{4})\b", value, ) if word_month_match: month_name = word_month_match.group(2).casefold().strip(".,") month = _RU_MONTHS.get(month_name) or _EN_MONTHS.get(month_name) if month: try: return date( int(word_month_match.group(3)), month, int(word_month_match.group(1)), ) except ValueError: pass english_month_match = re.search( r"\b([A-Za-z]+)\s+(\d{1,2}),?\s+(\d{4})\b", value, ) if english_month_match: month = _EN_MONTHS.get(english_month_match.group(1).casefold()) if month: try: return date( int(english_month_match.group(3)), month, int(english_month_match.group(2)), ) except ValueError: pass for fmt in ( "%Y%m%d", "%d%m%Y", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", ): try: return datetime.strptime(value[:19], fmt).date() except ValueError: continue return None def parse_time_value(value: str) -> str: value = str(value or "").strip() match = re.search(r"(?:T|\s|^)([0-2]?\d):([0-5]\d)(?::[0-5]\d)?", value) if not match: match = re.search(r"\b([0-2]?\d)[.-]([0-5]\d)\b", value) if not match: return value if re.fullmatch(r"\d{1,2}:\d{2}", value) else "" return f"{int(match.group(1)):02d}:{match.group(2)}" @dataclass(slots=True) class ParsedGame: external_id: str tournament_external_id: str tournament_type: str = "" game_number: str = "" round_number: str = "" round_name_ru: str = "" round_name_en: str = "" day_of_week: str = "" changes: int = 0 game_date: date | None = None start_time: str = "" start_datetime_raw: str = "" status: str = "scheduled" approved: bool = False arena_external_id: str = "" arena_ru: str = "" arena_en: str = "" arena_city_ru: str = "" arena_city_en: str = "" home_team_external_id: str = "" away_team_external_id: str = "" home_entry_external_id: str = "" away_entry_external_id: str = "" home_team_name_ru: str = "" home_team_name_en: str = "" home_team_short_ru: str = "" home_team_short_en: str = "" away_team_name_ru: str = "" away_team_name_en: str = "" away_team_short_ru: str = "" away_team_short_en: str = "" home_team_city_ru: str = "" home_team_city_en: str = "" away_team_city_ru: str = "" away_team_city_en: str = "" home_score: int = 0 away_score: int = 0 score_raw: str = "" overtime_type: str = "" period_1_score: str = "" period_2_score: str = "" period_3_score: str = "" period_4_score: str = "" series_score: str = "" attendance: int = 0 temporary_value: str = "" period_scores: str = "" raw_xml: str = "" @dataclass(slots=True) class MatchParseResult: games: list[ParsedGame] = field(default_factory=list) root_tag: str = "" item_tag: str = "" fields: list[str] = field(default_factory=list) candidate_nodes: int = 0 confidence: float = 0.0 sample: dict[str, Any] = field(default_factory=dict) ID_ALIASES = ( "id", "gameId", "game_id", "matchId", "match_id", "eventId", "event_id", "uid", "gameUid", "game_id_khl", ) TOURNAMENT_ALIASES = ( "tournamentId", "tournament_id", "competitionId", "championshipId", "seasonId", "stageId", ) DATE_ALIASES = ( "date", "gameDate", "game_date", "startDate", "start_date", "eventDate", "scheduledDate", "datetime", "dateTime", "startDateTime", "start_datetime", "dt", ) TIME_ALIASES = ( "time", "gameTime", "startTime", "start_time", "eventTime", "scheduledTime", "beginTime", "datetime", "dateTime", "startDateTime", ) STATUS_ALIASES = ( "status", "gameStatus", "state", "phase", "resultType", "finished", ) NUMBER_ALIASES = ( "number", "gameNumber", "game_number", "matchNumber", "num", "n", ) ARENA_RU_ALIASES = ( "arena", "arenaName", "arenaRu", "stadium", "venue", "place", "rink", "location", ) ARENA_EN_ALIASES = ( "arenaEn", "arenaNameEn", "stadiumEn", "venueEn", "placeEn", ) HOME_ID_ALIASES = ( "homeTeamId", "home_id", "team1Id", "teamAId", "hostId", "firstTeamId", "teamHomeId", ) AWAY_ID_ALIASES = ( "awayTeamId", "away_id", "team2Id", "teamBId", "guestId", "secondTeamId", "teamAwayId", ) HOME_RU_ALIASES = ( "homeTeam", "homeTeamName", "homeName", "team1", "team1Name", "teamA", "host", "hostName", "firstTeam", "teamHome", ) HOME_EN_ALIASES = ( "homeTeamEn", "homeTeamNameEn", "homeNameEn", "team1En", "team1NameEn", "teamAEn", "hostEn", "firstTeamEn", ) AWAY_RU_ALIASES = ( "awayTeam", "awayTeamName", "awayName", "team2", "team2Name", "teamB", "guest", "guestName", "secondTeam", "teamAway", ) AWAY_EN_ALIASES = ( "awayTeamEn", "awayTeamNameEn", "awayNameEn", "team2En", "team2NameEn", "teamBEn", "guestEn", "secondTeamEn", ) HOME_CITY_RU_ALIASES = ("homeCity", "team1City", "hostCity", "homeTeamCity") HOME_CITY_EN_ALIASES = ("homeCityEn", "team1CityEn", "hostCityEn") AWAY_CITY_RU_ALIASES = ("awayCity", "team2City", "guestCity", "awayTeamCity") AWAY_CITY_EN_ALIASES = ("awayCityEn", "team2CityEn", "guestCityEn") HOME_SCORE_ALIASES = ( "homeScore", "scoreHome", "score1", "team1Score", "goals1", "hostScore", "firstScore", ) AWAY_SCORE_ALIASES = ( "awayScore", "scoreAway", "score2", "team2Score", "goals2", "guestScore", "secondScore", ) PERIOD_SCORE_ALIASES = ( "periodScores", "periodScore", "scoreByPeriods", "periods", "sets", ) def flatten_node(node: ET.Element) -> dict[str, str]: values: dict[str, str] = {} for key, value in node.attrib.items(): if value is not None: values.setdefault(normalise_key(key), str(value).strip()) for child in list(node): child_key = normalise_key(child.tag) value = text_value(child) if value: values.setdefault(child_key, value) for key, attr_value in child.attrib.items(): attr_key = normalise_key(f"{local_name(child.tag)}_{key}") values.setdefault(attr_key, str(attr_value).strip()) values.setdefault(normalise_key(key), str(attr_value).strip()) for grandchild in list(child): grand_key = normalise_key( f"{local_name(child.tag)}_{local_name(grandchild.tag)}" ) grand_value = text_value(grandchild) if grand_value: values.setdefault(grand_key, grand_value) values.setdefault(normalise_key(grandchild.tag), grand_value) for key, attr_value in grandchild.attrib.items(): values.setdefault( normalise_key( f"{local_name(child.tag)}_{local_name(grandchild.tag)}_{key}" ), str(attr_value).strip(), ) return values def nested_team(node: ET.Element, side: str) -> dict[str, str]: side_names = { "home": { "home", "hometeam", "team1", "teama", "host", "firstteam", }, "away": { "away", "awayteam", "team2", "teamb", "guest", "secondteam", }, }[side] for child in node.iter(): if child is node: continue if normalise_key(child.tag) not in side_names: continue attrs = {normalise_key(k): str(v).strip() for k, v in child.attrib.items()} flat = flatten_node(child) merged = {**flat, **attrs} return { "id": first_value(merged, ("id", "teamId", "clubId", "externalId")), "name_ru": first_value( merged, ("name", "nameRu", "title", "teamName", "shortName") ) or text_value(child), "name_en": first_value( merged, ("nameEn", "titleEn", "teamNameEn", "shortNameEn") ), "city_ru": first_value(merged, ("city", "cityRu", "town")), "city_en": first_value(merged, ("cityEn", "townEn")), "score": first_value(merged, ("score", "goals", "result")), } return {} def looks_like_game_node(node: ET.Element, values: dict[str, str]) -> bool: tag = normalise_key(node.tag) if tag in {"game", "match", "event", "fixture", "calendaritem"}: return True has_date = bool(first_value(values, DATE_ALIASES)) has_home = bool( first_value(values, HOME_RU_ALIASES + HOME_ID_ALIASES) or nested_team(node, "home") ) has_away = bool( first_value(values, AWAY_RU_ALIASES + AWAY_ID_ALIASES) or nested_team(node, "away") ) return has_date and has_home and has_away def parse_score_pair(value: str) -> tuple[int, int]: match = re.fullmatch(r"\s*(\d+)\s*:\s*(\d+)\s*", str(value or "")) if not match: return 0, 0 return int(match.group(1)), int(match.group(2)) def truthy(value: Any) -> bool: return str(value or "").strip().lower() in { "1", "true", "yes", "on", "approved", } def derive_schedule_status(attrs: dict[str, str]) -> str: approved = truthy(attrs.get("approved")) score = str(attrs.get("score") or "").strip() temporary = str(attrs.get("temp") or "").strip() if approved: return "finished" if temporary or re.fullmatch(r"\s*\d+\s*:\s*\d+\s*", score): return "live" return "scheduled" def parsed_schedule_game( node: ET.Element, *, tournament_external_id: str, tournament_type: str, ) -> ParsedGame | None: attrs = {str(key): str(value or "") for key, value in node.attrib.items()} external_id = attrs.get("id", "").strip() if not external_id: return None score_raw = attrs.get("score", "").strip() home_score, away_score = parse_score_pair(score_raw) period_values = [ attrs.get("scP1", "").strip(), attrs.get("scP2", "").strip(), attrs.get("scP3", "").strip(), attrs.get("scP4", "").strip(), ] return ParsedGame( external_id=external_id, tournament_external_id=( attrs.get("tnId", "").strip() or tournament_external_id ), tournament_type=tournament_type, game_number=attrs.get("number", "").strip(), round_number=attrs.get("round", "").strip(), round_name_ru=attrs.get("roundname", "").strip(), round_name_en=attrs.get("roundname_en", "").strip(), day_of_week=attrs.get("dayofweek", "").strip(), changes=safe_int(attrs.get("changes")), game_date=parse_date_value(attrs.get("date", "")), start_time=parse_time_value(attrs.get("time", "")), start_datetime_raw=( f"{attrs.get('date', '').strip()} " f"{attrs.get('time', '').strip()}" ).strip(), status=derive_schedule_status(attrs), approved=truthy(attrs.get("approved")), arena_external_id=attrs.get("arenaid", "").strip(), arena_ru=attrs.get("arena", "").strip(), arena_en=attrs.get("arena_en", "").strip(), arena_city_ru=attrs.get("arena_city", "").strip(), arena_city_en=attrs.get("arena_city_en", "").strip(), home_team_external_id=attrs.get("teama", "").strip(), away_team_external_id=attrs.get("teamb", "").strip(), home_entry_external_id=attrs.get("homeId", "").strip(), away_entry_external_id=attrs.get("visitorId", "").strip(), home_team_name_ru=attrs.get("homeName", "").strip(), home_team_name_en=attrs.get("homeName_en", "").strip(), home_team_short_ru=attrs.get("homeName_l3", "").strip(), home_team_short_en=attrs.get("homeName_l3_en", "").strip(), away_team_name_ru=attrs.get("visitorName", "").strip(), away_team_name_en=attrs.get("visitorName_en", "").strip(), away_team_short_ru=attrs.get("visitorName_l3", "").strip(), away_team_short_en=attrs.get("visitorName_l3_en", "").strip(), home_team_city_ru=attrs.get("homeCity", "").strip(), home_team_city_en=attrs.get("homeCity_en", "").strip(), away_team_city_ru=attrs.get("visitorCity", "").strip(), away_team_city_en=attrs.get("visitorCity_en", "").strip(), home_score=home_score, away_score=away_score, score_raw=score_raw, overtime_type=attrs.get("ots", "").strip(), period_1_score=period_values[0], period_2_score=period_values[1], period_3_score=period_values[2], period_4_score=period_values[3], series_score=attrs.get("sscore", "").strip(), attendance=safe_int(attrs.get("attendance")), temporary_value=attrs.get("temp", "").strip(), period_scores="|".join(period_values), raw_xml=ET.tostring(node, encoding="unicode"), ) def parse_stat2tv_schedule( root: ET.Element, *, tournament_external_id: str, ) -> MatchParseResult: result = MatchParseResult(root_tag=local_name(root.tag), item_tag="Game") tournament_id = str( root.attrib.get("tournamentId") or tournament_external_id ).strip() tournament_type = str( root.attrib.get("tournamentType") or "" ).strip().lower() field_names: set[str] = set() for node in root.findall("./Game"): field_names.update(str(key) for key in node.attrib) game = parsed_schedule_game( node, tournament_external_id=tournament_id, tournament_type=tournament_type, ) if game is not None: result.games.append(game) result.candidate_nodes = len(result.games) result.fields = sorted(field_names) result.confidence = 1.0 if result.games else 0.0 if result.games: sample = result.games[0] result.sample = { "external_id": sample.external_id, "date": sample.game_date.isoformat() if sample.game_date else None, "time": sample.start_time, "home": sample.home_team_name_ru or sample.home_team_name_en, "away": sample.away_team_name_ru or sample.away_team_name_en, "status": sample.status, "approved": sample.approved, "score": sample.score_raw, "series_score": sample.series_score, "tournament_type": sample.tournament_type, } return result def parse_stat2tv_game_by_id( content: bytes | str, *, tournament_external_id: str, game_external_id: str, ) -> ParsedGame | None: """Parse only one game from a Stat2TV schedule response.""" raw = content.encode("utf-8") if isinstance(content, str) else content root = ET.fromstring(raw) if normalise_key(root.tag) != "schedule": result = parse_games_xml( raw, tournament_external_id=tournament_external_id, ) return next( ( game for game in result.games if str(game.external_id) == str(game_external_id) ), None, ) tournament_id = str( root.attrib.get("tournamentId") or tournament_external_id ).strip() tournament_type = str( root.attrib.get("tournamentType") or "" ).strip().lower() node = root.find(f"./Game[@id='{str(game_external_id)}']") if node is None: return None return parsed_schedule_game( node, tournament_external_id=tournament_id, tournament_type=tournament_type, ) def parse_games_xml( content: bytes | str, *, tournament_external_id: str, ) -> MatchParseResult: raw = content.encode("utf-8") if isinstance(content, str) else content root = ET.fromstring(raw) if ( normalise_key(root.tag) == "schedule" and root.findall("./Game") ): return parse_stat2tv_schedule( root, tournament_external_id=tournament_external_id, ) result = MatchParseResult(root_tag=local_name(root.tag)) nodes: list[tuple[ET.Element, dict[str, str]]] = [] for node in root.iter(): if node is root: continue values = flatten_node(node) if looks_like_game_node(node, values): nodes.append((node, values)) # Avoid parsing nested team/event nodes as games when their parent is already a game. node_ids = {id(node) for node, _ in nodes} filtered_nodes: list[tuple[ET.Element, dict[str, str]]] = [] for node, values in nodes: parent_is_candidate = False for parent, _ in nodes: if parent is node: continue if any(child is node for child in parent.iter()): parent_is_candidate = True break if not parent_is_candidate: filtered_nodes.append((node, values)) if not filtered_nodes: filtered_nodes = nodes result.candidate_nodes = len(filtered_nodes) seen: set[str] = set() field_names: set[str] = set() for index, (node, values) in enumerate(filtered_nodes, start=1): field_names.update(values.keys()) home_nested = nested_team(node, "home") away_nested = nested_team(node, "away") raw_datetime = first_value(values, DATE_ALIASES) raw_time = first_value(values, TIME_ALIASES) game_date = parse_date_value(raw_datetime) start_time = parse_time_value(raw_time or raw_datetime) external_id = first_value(values, ID_ALIASES) if not external_id: material = ( f"{tournament_external_id}|{raw_datetime}|" f"{first_value(values, HOME_RU_ALIASES)}|" f"{first_value(values, AWAY_RU_ALIASES)}|{index}" ) external_id = "auto-" + hashlib.sha1( material.encode("utf-8", errors="ignore") ).hexdigest()[:20] if external_id in seen: continue seen.add(external_id) game = ParsedGame( external_id=external_id, tournament_external_id=( first_value(values, TOURNAMENT_ALIASES) or tournament_external_id ), game_number=first_value(values, NUMBER_ALIASES), game_date=game_date, start_time=start_time, start_datetime_raw=raw_datetime or raw_time, status=first_value(values, STATUS_ALIASES), arena_ru=first_value(values, ARENA_RU_ALIASES), arena_en=first_value(values, ARENA_EN_ALIASES), home_team_external_id=( first_value(values, HOME_ID_ALIASES) or home_nested.get("id", "") ), away_team_external_id=( first_value(values, AWAY_ID_ALIASES) or away_nested.get("id", "") ), home_team_name_ru=( first_value(values, HOME_RU_ALIASES) or home_nested.get("name_ru", "") ), home_team_name_en=( first_value(values, HOME_EN_ALIASES) or home_nested.get("name_en", "") ), away_team_name_ru=( first_value(values, AWAY_RU_ALIASES) or away_nested.get("name_ru", "") ), away_team_name_en=( first_value(values, AWAY_EN_ALIASES) or away_nested.get("name_en", "") ), home_team_city_ru=( first_value(values, HOME_CITY_RU_ALIASES) or home_nested.get("city_ru", "") ), home_team_city_en=( first_value(values, HOME_CITY_EN_ALIASES) or home_nested.get("city_en", "") ), away_team_city_ru=( first_value(values, AWAY_CITY_RU_ALIASES) or away_nested.get("city_ru", "") ), away_team_city_en=( first_value(values, AWAY_CITY_EN_ALIASES) or away_nested.get("city_en", "") ), home_score=safe_int( first_value(values, HOME_SCORE_ALIASES) or home_nested.get("score", "") ), away_score=safe_int( first_value(values, AWAY_SCORE_ALIASES) or away_nested.get("score", "") ), period_scores=first_value(values, PERIOD_SCORE_ALIASES), raw_xml=ET.tostring(node, encoding="unicode"), ) result.games.append(game) if result.games: result.item_tag = local_name(filtered_nodes[0][0].tag) complete = sum( 1 for game in result.games if game.game_date and game.home_team_name_ru and game.away_team_name_ru ) result.confidence = round(complete / max(1, len(result.games)), 3) sample_game = result.games[0] result.sample = { "external_id": sample_game.external_id, "date": sample_game.game_date.isoformat() if sample_game.game_date else None, "time": sample_game.start_time, "home": sample_game.home_team_name_ru or sample_game.home_team_name_en, "away": sample_game.away_team_name_ru or sample_game.away_team_name_en, "status": sample_game.status, } result.fields = sorted(field_names) return result