from __future__ import annotations import re from collections import OrderedDict, defaultdict from typing import Any from xml.etree import ElementTree as ET from .match_parser import local_name _SYSTEM_KEYS = { "id", "external_id", "idtournament", "id_tournament", "tournament_id", "tournamentid", "gendate", "gen_date", "generated_at", "date", "language", "lang", "type", "category", "category_id", } _NAME_KEYS_RU = ( "name", "fullname", "full_name", "player", "player_name", "fio", "title", ) _NAME_KEYS_EN = ( "nameen", "name_en", "fullnameen", "fullname_en", "playeren", "player_en", "titleen", "title_en", ) _TEAM_KEYS_RU = ( "team", "team_name", "teamname", "team_title", "teamtitle", "team_short_name", "teamshortname", "team_name_ru", "team_name_rus", "team_ru", "club", "club_name", "clubname", "club_title", "clubtitle", "club_short_name", "clubshortname", "club_name_ru", "club_name_rus", "club_ru", ) _TEAM_KEYS_EN = ( "teamen", "team_en", "team_name_en", "teamnameen", "team_title_en", "teamtitleen", "team_short_name_en", "teamshortnameen", "cluben", "club_en", "club_name_en", "clubnameen", "club_title_en", "clubtitleen", "club_short_name_en", "clubshortnameen", ) _TEAM_ID_KEYS = ( "team_id", "teamid", "club_id", "clubid", "team_entry_id", "teamentryid", "club_entry_id", "clubentryid", "entry_id", "entryid", ) _PLAYER_ID_KEYS = ( "player_id", "playerid", "person_id", "personid", "athlete_id", "athleteid", ) _RANK_KEYS = ("rank", "position", "place", "pos") _NUMBER_KEYS = ("number", "num", "jersey", "jersey_number", "shirt_number") _PERIOD_KEYS_RU = ( "period", "period_ru", "period_name", "periodname", "stage", "stage_name", ) _PERIOD_KEYS_EN = ( "perioden", "period_en", "period_name_en", "periodnameen", "stageen", "stage_en", "stage_name_en", ) _LABELS: dict[str, tuple[str, str]] = { "rank": ("Место", "Rank"), "position": ("Место", "Position"), "place": ("Место", "Place"), "pos": ("Место", "Position"), "number": ("№", "No."), "num": ("№", "No."), "jersey": ("№", "No."), "jersey_number": ("№", "No."), "gp": ("И", "GP"), "games": ("И", "GP"), "games_played": ("И", "GP"), "g": ("Г", "G"), "goals": ("Голы", "Goals"), "a": ("П", "A"), "assists": ("Передачи", "Assists"), "pts": ("О", "PTS"), "points": ("Очки", "Points"), "plus_minus": ("+/-", "+/-"), "plusminus": ("+/-", "+/-"), "pim": ("Штр", "PIM"), "penalty_minutes": ("Штраф", "PIM"), "ppg": ("ГБ", "PPG"), "power_play_goals": ("Голы в большинстве", "Power-play goals"), "ppa": ("ПБ", "PPA"), "power_play_assists": ("Передачи в большинстве", "Power-play assists"), "ppp": ("ОБ", "PPP"), "power_play_points": ("Очки в большинстве", "Power-play points"), "ppo": ("Попытки", "Opportunities"), "power_play_opportunities": ("Большинств", "Power-play opportunities"), "pp_pct": ("Реализация, %", "Power play, %"), "pp_percent": ("Реализация, %", "Power play, %"), "power_play_pct": ("Реализация, %", "Power play, %"), "power_play_percent": ("Реализация, %", "Power play, %"), "shg": ("ГМ", "SHG"), "short_handed_goals": ("Голы в меньшинстве", "Short-handed goals"), "shga": ("Пропущено в меньшинстве", "SHGA"), "pk": ("Нейтрализации", "Penalty kills"), "pko": ("Меньшинств", "Times short-handed"), "pk_pct": ("Надёжность меньшинства, %", "Penalty kill, %"), "penalty_kill_pct": ("Надёжность меньшинства, %", "Penalty kill, %"), "toi": ("Время", "TOI"), "time_on_ice": ("Время на льду", "Time on ice"), "avg_toi": ("Среднее время", "Average TOI"), "shots": ("Броски", "Shots"), "sog": ("Броски в створ", "SOG"), "wins": ("Победы", "Wins"), "losses": ("Поражения", "Losses"), "save_pct": ("% отражённых", "Save %"), "gaa": ("КН", "GAA"), } def _text(value: Any) -> str: return str(value or "").strip() def _key(value: Any) -> str: text = local_name(str(value or "")).strip() text = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", text) text = re.sub(r"[^A-Za-zА-Яа-я0-9]+", "_", text) return text.strip("_").lower() def _element_fields(node: ET.Element) -> OrderedDict[str, str]: result: OrderedDict[str, str] = OrderedDict() for raw_key, raw_value in node.attrib.items(): key = _key(raw_key) if key: result[key] = _text(raw_value) # Keep parent context for nested identity objects such as # ....... # Without this, the team's generic Id/Name would overwrite the player's. parent_by_id: dict[int, ET.Element] = {} for parent in node.iter(): for child in list(parent): parent_by_id[id(child)] = parent identity_containers = {"team", "club", "player", "person", "athlete"} for child in node.iter(): if child is node or list(child): continue child_attrs = {_key(k): _text(v) for k, v in child.attrib.items()} field_name = child_attrs.get("name") or child_attrs.get("key") field_value = child_attrs.get("value") if field_name and field_value not in (None, ""): result[_key(field_name)] = field_value continue value = _text(child.text) or child_attrs.get("value", "") key = _key(child.tag) if not key or not value: continue parent = parent_by_id.get(id(child)) parent_key = _key(parent.tag) if parent is not None else "" if parent_key in identity_containers and key in { "id", "external_id", "name", "name_en", "title", "title_en", "short_name", "short_name_en", "number", }: # Direct player fields keep their ordinary keys. Nested team/club # fields receive a prefix so they cannot overwrite player identity. if parent is node and parent_key in {"player", "person", "athlete"}: result.setdefault(key, value) else: result[f"{parent_key}_{key}"] = value continue # First scalar wins. This is important when different nested objects use # the same generic tag names. result.setdefault(key, value) return result def _first(fields: dict[str, str], keys: tuple[str, ...]) -> str: for key in keys: value = _text(fields.get(key)) if value: return value return "" def _section_label(node: ET.Element, language: str, fallback: str) -> tuple[str, dict[str, str]]: fields = {_key(key): _text(value) for key, value in node.attrib.items()} ru = _first(fields, ("name", "title", "label", "category", "type")) en = _first(fields, ("nameen", "name_en", "titleen", "title_en", "labelen", "label_en")) tag = local_name(node.tag) fallback_ru = { "powerplay": "Большинство", "rank": "Рейтинг", "ranking": "Рейтинг", "players": "Игроки", "teams": "Команды", }.get(_key(tag), fallback) fallback_en = { "powerplay": "Power play", "rank": "Ranking", "ranking": "Ranking", "players": "Players", "teams": "Teams", }.get(_key(tag), fallback) ru = ru or fallback_ru en = en or fallback_en return (en if language == "en" and en else ru or en, {"ru": ru, "en": en}) def _section_period(node: ET.Element) -> dict[str, str]: fields = {_key(key): _text(value) for key, value in node.attrib.items()} period_ru = _first(fields, _PERIOD_KEYS_RU) period_en = _first(fields, _PERIOD_KEYS_EN) # Most Stat2TV feeds expose only one language-neutral `period` value. period_ru = period_ru or period_en period_en = period_en or period_ru return {"ru": period_ru, "en": period_en} def _label_with_period(label: str, period: str, language: str) -> str: label = _text(label) period = _text(period) if not period: return label # Do not repeat the period when the feed already included it in `name`. if _key(period) and _key(period) in _key(label): return label period_word = "period" if language == "en" else "период" return f"{label} · {period_word} {period}".strip() def _leaf_row_candidates(root: ET.Element) -> list[tuple[ET.Element, ET.Element, OrderedDict[str, str]]]: parent_by_id: dict[int, ET.Element] = {} for parent in root.iter(): for child in list(parent): parent_by_id[id(child)] = parent candidates: list[tuple[ET.Element, ET.Element, OrderedDict[str, str]]] = [] technical_tags = {"stat", "field", "value", "param", "parameter", "property"} technical_keys = {"name", "key", "label", "value"} for node in root.iter(): if node is root: continue parent_node = parent_by_id.get(id(node)) node_tag = _key(node.tag) parent_tag = _key(parent_node.tag) if parent_node is not None else "" if node_tag in {"team", "club"} and parent_tag in {"player", "person", "athlete"}: continue fields = _element_fields(node) if len(fields) < 2: continue # Nested nodes describe a parent row and # must not be rendered as standalone records. if _key(node.tag) in technical_tags and set(fields).issubset(technical_keys): continue nested_data_rows = 0 for child in list(node): child_fields = _element_fields(child) child_tag = _key(child.tag) if child_tag in technical_tags and set(child_fields).issubset(technical_keys): continue if _key(node.tag) in {"player", "person", "athlete"} and child_tag in { "team", "club", "stats", "statistics", }: continue if len(child_fields) >= 2 and ( _first(child_fields, _NAME_KEYS_RU + _NAME_KEYS_EN + _TEAM_KEYS_RU + _TEAM_KEYS_EN) or _first(child_fields, _RANK_KEYS) ): nested_data_rows += 1 if nested_data_rows: continue parent = parent_by_id.get(id(node), root) candidates.append((parent, node, fields)) return candidates def _column_label(key: str, language: str) -> str: labels = _LABELS.get(key) if labels: return labels[1] if language == "en" else labels[0] return key.replace("_", " ").strip().title() def _column_format(key: str) -> str: if "pct" in key or "percent" in key: return "percent" if key in {"toi", "avg_toi", "time", "time_on_ice"} or "time" in key: return "time" return "text" def parse_tournament_statistics_xml( content: bytes | str, *, resource_type: str, language: str = "ru", ) -> dict[str, Any]: raw = content.encode("utf-8") if isinstance(content, str) else content root = ET.fromstring(raw) kind = _key(resource_type) root_fields = {_key(key): _text(value) for key, value in root.attrib.items()} grouped: dict[int, list[tuple[ET.Element, OrderedDict[str, str]]]] = defaultdict(list) parents: dict[int, ET.Element] = {} for parent, node, fields in _leaf_row_candidates(root): grouped[id(parent)].append((node, fields)) parents[id(parent)] = parent sections: list[dict[str, Any]] = [] seen_signatures: set[tuple[str, ...]] = set() used_section_ids: set[str] = set() for section_index, (parent_id, entries) in enumerate(grouped.items(), start=1): parent = parents[parent_id] row_payloads: list[dict[str, Any]] = [] column_order: OrderedDict[str, None] = OrderedDict() for row_index, (node, fields) in enumerate(entries, start=1): name_ru = _first(fields, _NAME_KEYS_RU) name_en = _first(fields, _NAME_KEYS_EN) team_ru = _first(fields, _TEAM_KEYS_RU) team_en = _first(fields, _TEAM_KEYS_EN) # In team-based XML the generic name attribute is the team name. if kind == "powerplay" and not team_ru and not team_en: team_ru, team_en = name_ru, name_en name_ru = name_en = "" identity_keys = set( _NAME_KEYS_RU + _NAME_KEYS_EN + _TEAM_KEYS_RU + _TEAM_KEYS_EN + _TEAM_ID_KEYS + _PLAYER_ID_KEYS + _RANK_KEYS + _NUMBER_KEYS ) values: OrderedDict[str, str] = OrderedDict() for key, value in fields.items(): if key in identity_keys or key in _SYSTEM_KEYS: continue if value == "": continue values[key] = value column_order.setdefault(key, None) rank = _first(fields, _RANK_KEYS) number = _first(fields, _NUMBER_KEYS) team_id = _first(fields, _TEAM_ID_KEYS) player_id = _first(fields, _PLAYER_ID_KEYS) display_name = ( team_en if language == "en" and team_en else team_ru or team_en ) if kind == "powerplay" else ( name_en if language == "en" and name_en else name_ru or name_en ) display_team = team_en if language == "en" and team_en else team_ru or team_en # Keep unknown but useful rows. Completely empty technical nodes are ignored. if not (display_name or display_team or rank or number or values): continue row_payloads.append( { "id": _first(fields, ("id", "external_id")) or player_id or f"row-{row_index}", "player_id": player_id or ( _first(fields, ("id", "external_id")) if kind == "rank" else "" ), "team_id": team_id, "rank": rank, "number": number, "name": display_name, "names": {"ru": name_ru, "en": name_en}, "team": display_team, "team_names": {"ru": team_ru, "en": team_en}, "values": dict(values), "source_tag": local_name(node.tag), } ) if not row_payloads: continue signature = tuple( sorted( f"{row.get('id')}|{row.get('rank')}|{row.get('name')}|{row.get('team')}" for row in row_payloads ) ) if signature in seen_signatures: continue seen_signatures.add(signature) fallback = "Power play" if kind == "powerplay" else "Ranking" label, names = _section_label(parent, language, fallback) period_names = _section_period(parent) if kind == "rank" and (period_names["ru"] or period_names["en"]): names = { "ru": _label_with_period(names.get("ru", ""), period_names["ru"], "ru"), "en": _label_with_period(names.get("en", ""), period_names["en"], "en"), } label = names["en"] if language == "en" else names["ru"] # Some Stat2TV feeds expose categories as repeated nodes without an id, # for example , . Using only # the parent tag made every UI tab receive the same id ("category"), so # all tabs looked active and clicks could not switch the table. Prefer # the XML id, then the category label, and always make the result unique. base_section_id = ( _text(parent.attrib.get("id")) or _key(label) or _key(parent.tag) or f"section-{section_index}" ) section_id = base_section_id duplicate_index = 2 while section_id in used_section_ids: section_id = f"{base_section_id}-{duplicate_index}" duplicate_index += 1 used_section_ids.add(section_id) columns = [ { "key": key, "label": _column_label(key, language), "format": _column_format(key), } for key in column_order ] sections.append( { "id": section_id, "label": label, "names": names, "period": period_names["en"] if language == "en" else period_names["ru"], "periods": period_names, "rows": row_payloads, "columns": columns, } ) # Some feeds use the root itself as a single record. Preserve it as a fallback. if not sections: root_values = _element_fields(root) if len(root_values) >= 2: values = { key: value for key, value in root_values.items() if key not in _SYSTEM_KEYS and value != "" } if values: fallback = "Power play" if kind == "powerplay" else "Ranking" label, names = _section_label(root, language, fallback) sections.append( { "id": _key(root.tag) or kind, "label": label, "names": names, "rows": [{ "id": _first(root_values, ("id", "external_id")) or "row-1", "player_id": _first(root_values, _PLAYER_ID_KEYS), "team_id": _first(root_values, _TEAM_ID_KEYS), "rank": _first(root_values, _RANK_KEYS), "number": _first(root_values, _NUMBER_KEYS), "name": _first(root_values, _NAME_KEYS_RU), "names": { "ru": _first(root_values, _NAME_KEYS_RU), "en": _first(root_values, _NAME_KEYS_EN), }, "team": _first(root_values, _TEAM_KEYS_RU), "team_names": { "ru": _first(root_values, _TEAM_KEYS_RU), "en": _first(root_values, _TEAM_KEYS_EN), }, "values": values, "source_tag": local_name(root.tag), }], "columns": [ {"key": key, "label": _column_label(key, language), "format": _column_format(key)} for key in values ], } ) tournament_id = _first( root_fields, ("idtournament", "id_tournament", "tournament_id", "tournamentid", "tournament"), ) generated_at = _first( root_fields, ("gendate", "gen_date", "generated_at", "generated", "updated_at", "date"), ) return { "resource_type": kind, "root_tag": local_name(root.tag), "tournament_id": tournament_id, "generated_at": generated_at, "available": any(section.get("rows") for section in sections), "sections": sections, }