Files
hockey_new/hockey_data/match_visuals_parser.py
2026-08-19 15:08:39 +03:00

645 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import re
from collections.abc import Iterable, Mapping
from typing import Any
def _key(value: Any) -> str:
return "".join(ch for ch in str(value or "").casefold() if ch.isalnum())
def _text(value: Any) -> str:
if value is None:
return ""
if isinstance(value, bool):
return "1" if value else "0"
return str(value).strip()
def _number(value: Any) -> float | None:
if value is None or isinstance(value, bool):
return None
text = _text(value).replace(",", ".")
if not text:
return None
try:
return float(text)
except (TypeError, ValueError):
return None
def _truthy(value: Any) -> bool:
return _text(value).casefold() in {"1", "true", "yes", "y", "on", "goal", "гол"}
def _mapping(value: Any) -> dict[str, Any]:
return dict(value) if isinstance(value, Mapping) else {}
def _lowered(row: Mapping[str, Any]) -> dict[str, Any]:
return {_key(key): value for key, value in row.items()}
def _first(row: Mapping[str, Any], aliases: Iterable[str]) -> Any:
lowered = _lowered(row)
for alias in aliases:
normalized = _key(alias)
if normalized in lowered and lowered[normalized] not in (None, ""):
return lowered[normalized]
return None
def _identity(value: Any) -> tuple[str, str, str]:
if isinstance(value, Mapping):
value_map = _mapping(value)
identifier = _text(_first(value_map, ("id", "external_id", "player_id", "team_id", "club_id")))
name = _text(_first(value_map, ("name", "fullname", "full_name", "title", "team", "player")))
number = _text(_first(value_map, ("number", "jersey", "jersey_number", "num", "pn")))
return identifier, name, number
return "", _text(value), ""
def _period_from_key(value: Any) -> str:
text = _text(value).casefold()
normalized = _key(text)
if normalized.isdigit():
return str(int(normalized))
if normalized in {"ot", "overtime", "extra", "extratime", "ovt", "допвремя", "от"}:
return "4"
if normalized in {"so", "shootout", "bullits", "bullit", "буллиты", "б"}:
return "5"
for prefix in ("period", "per", "p", "time", "segment"):
if normalized.startswith(prefix) and normalized[len(prefix):].isdigit():
return str(int(normalized[len(prefix):]))
return ""
def _side_from_key(value: Any) -> str:
normalized = _key(value)
if normalized in {"a", "home", "host", "left", "teama", "clubа", "хозяева"}:
return "home"
if normalized in {"b", "away", "guest", "visitor", "right", "teamb", "гости"}:
return "away"
return ""
def _normalised_values(values: Iterable[Any]) -> set[str]:
return {_key(value) for value in values if _text(value)}
def _side_for_identity(
*,
explicit_side: str,
team_id: str,
team_name: str,
home_ids: set[str],
away_ids: set[str],
home_names: set[str],
away_names: set[str],
) -> str:
if explicit_side in {"home", "away"}:
return explicit_side
candidate_id = _key(team_id)
candidate_name = _key(team_name)
if candidate_id and candidate_id in home_ids:
return "home"
if candidate_id and candidate_id in away_ids:
return "away"
if candidate_name and candidate_name in home_names:
return "home"
if candidate_name and candidate_name in away_names:
return "away"
return ""
def _find_container(data: Mapping[str, Any], aliases: Iterable[str]) -> Any:
lowered = _lowered(data)
for alias in aliases:
if _key(alias) in lowered:
return lowered[_key(alias)]
for wrapper in ("data", "result", "statistics", "stats", "match", "game_data"):
nested = lowered.get(_key(wrapper))
if isinstance(nested, Mapping):
found = _find_container(nested, aliases)
if found is not None:
return found
return None
def _looks_like_event(row: Mapping[str, Any]) -> bool:
keys = set(_lowered(row))
type_keys = {_key(value) for value in ("event", "type", "event_type", "code", "action", "kind", "description", "text")}
person_keys = {_key(value) for value in ("player", "player_id", "scorer", "author", "team", "team_id", "club", "pn", "pname")}
return bool(keys & type_keys and (keys & person_keys or "time" in keys or "moscowtime" in keys))
def _looks_like_shot(row: Mapping[str, Any]) -> bool:
keys = set(_lowered(row))
x_keys = {_key(value) for value in ("x", "coord_x", "x_coord", "pos_x", "point_x", "shot_x", "sx")}
y_keys = {_key(value) for value in ("y", "coord_y", "y_coord", "pos_y", "point_y", "shot_y", "sy")}
if keys & x_keys and keys & y_keys:
return True
coords = _text(_first(row, ("coords", "coordinate", "coordinates", "point", "position", "location")))
if re.fullmatch(r"\s*-?\d+(?:[.,]\d+)?\s*[xх,:;]\s*-?\d+(?:[.,]\d+)?\s*", coords, re.I):
return True
for nested_key in ("coordinate", "coordinates", "coords", "point", "position", "location"):
nested = _first(row, (nested_key,))
if isinstance(nested, Mapping):
nested_keys = set(_lowered(nested))
if nested_keys & x_keys and nested_keys & y_keys:
return True
if isinstance(nested, (list, tuple)) and len(nested) >= 2:
return _number(nested[0]) is not None and _number(nested[1]) is not None
return False
def _flatten_rows(
value: Any,
*,
kind: str,
inherited_period: str = "",
inherited_side: str = "",
) -> Iterable[tuple[dict[str, Any], str, str]]:
checker = _looks_like_event if kind == "event" else _looks_like_shot
if isinstance(value, list):
for item in value:
yield from _flatten_rows(item, kind=kind, inherited_period=inherited_period, inherited_side=inherited_side)
return
if not isinstance(value, Mapping):
return
row = _mapping(value)
if checker(row):
yield row, inherited_period, inherited_side
return
for key, child in row.items():
next_period = _period_from_key(key) or inherited_period
next_side = _side_from_key(key) or inherited_side
if isinstance(child, (Mapping, list)):
yield from _flatten_rows(child, kind=kind, inherited_period=next_period, inherited_side=next_side)
def _period_sort(value: str) -> tuple[int, str]:
number = _number(value)
return (int(number) if number is not None else 999, str(value))
def _game_seconds(value: Any) -> int | None:
match = re.fullmatch(r"\s*(\d{1,3}):(\d{2})\s*", _text(value))
if not match:
return None
return int(match.group(1)) * 60 + int(match.group(2))
def _infer_period(event_time: str, code: str, status: str = "") -> str:
normalized_status = _key(status)
normalized_code = _key(code)
if normalized_code == "bu" or "shootout" in normalized_status or "буллит" in normalized_status:
return "5"
seconds = _game_seconds(event_time)
if seconds is None:
return ""
if seconds < 20 * 60:
return "1"
if seconds < 40 * 60:
return "2"
if seconds < 60 * 60:
return "3"
if seconds < 65 * 60:
return "4"
return "5"
PENALTY_RU = {
"tripping": "Подножка",
"slashing": "Удар клюшкой",
"boarding": "Толчок на борт",
"roughing": "Грубость",
"hooking": "Задержка соперника клюшкой",
"holding": "Задержка соперника",
"interference": "Блокировка",
"too many players on the ice": "Нарушение численного состава",
"delay of game": "Задержка игры",
"high-sticking": "Игра высоко поднятой клюшкой",
"cross-checking": "Толчок клюшкой",
"charging": "Неправильная атака",
"elbowing": "Удар локтем",
}
def _translated_penalty_reason(value: Any, language: str) -> str:
text = _text(value)
if language == "en" or not text:
return text
return PENALTY_RU.get(text.casefold(), text)
def _translate_common_text(value: Any, language: str) -> str:
text = _text(value)
if language == "en" or not text:
return text
exact = {
"Commercial break": "Рекламная пауза",
"The last minute of the period": "Последняя минута периода",
"The last 2 minutes of the third period": "Последние 2 минуты третьего периода",
}
if text in exact:
return exact[text]
replacements = [
(r"^Team (.+) is playing in full strength$", r"Команда \1 играет в полном составе"),
(r"^Delayed penalty for (.+)$", r"Отложенный штраф у команды \1"),
(r"^Icing\. A face-off in the defensive zone of (.+)$", r"Проброс. Вбрасывание в зоне защиты команды \1"),
(r"^Offside against the (.+)\. A face-off in the neutral zone$", r"Положение «вне игры» у команды \1. Вбрасывание в средней зоне"),
(r"^(\d+) on (\d+) play$", r"Игра \1 на \2"),
]
for pattern, replacement in replacements:
if re.match(pattern, text, re.I):
return re.sub(pattern, replacement, text, flags=re.I)
return text
def _event_label(code: str, description: str, language: str) -> str:
normalized = _key(code or description)
labels = {
"go": ("Гол", "Goal"),
"goal": ("Гол", "Goal"),
"g": ("Гол", "Goal"),
"score": ("Гол", "Goal"),
"pn": ("Удаление", "Penalty"),
"penalty": ("Удаление", "Penalty"),
"pen": ("Удаление", "Penalty"),
"bu": ("Буллит", "Shootout attempt"),
"shot": ("Бросок", "Shot"),
"faceoff": ("Вбрасывание", "Faceoff"),
"hit": ("Силовой приём", "Hit"),
"to": ("Тайм-аут", "Timeout"),
"timeout": ("Тайм-аут", "Timeout"),
"ga": ("Ход матча", "Game state"),
"periodstart": ("Начало периода", "Period start"),
"periodend": ("Конец периода", "Period end"),
"gc": ("Замена вратаря", "Goalie change"),
"goaliechange": ("Замена вратаря", "Goalie change"),
"co": ("Комментарий", "Comment"),
"ce": ("Событие", "Event"),
}
if normalized in labels:
return labels[normalized][1 if language == "en" else 0]
return description or code or ("Событие" if language != "en" else "Event")
def _period_title(period: str, language: str) -> str:
if period == "0":
return "игры" if language != "en" else "game"
if period == "4":
return "овертайма" if language != "en" else "overtime"
if period == "5":
return "серии буллитов" if language != "en" else "shootout"
return f"{period} периода" if language != "en" else f"period {period}"
def _event_content(row: Mapping[str, Any], code: str, period: str, language: str) -> tuple[str, str, str]:
normalized = _key(code)
team = _text(_first(row, ("team_name", "club_name", "team_title", "team")))
player_number = _text(_first(row, ("pn", "player_number", "number", "jersey")))
player_name = _text(_first(row, ("pname", "player_name", "fullname", "full_name", "scorer_name")))
player = " ".join(part for part in ((f"{player_number}" if player_number else ""), player_name) if part)
if normalized in {"go", "goal", "score"}:
status = _text(_first(row, ("status", "pmStatus", "strength")))
assists = []
for index in (1, 2):
number = _text(_first(row, (f"assist{index}pn", f"assist{index}_number")))
name = _text(_first(row, (f"assist{index}pname", f"assist{index}_name")))
if number or name:
assists.append(" ".join(part for part in ((f"{number}" if number else ""), name) if part))
title = "Гол" if language != "en" else "Goal"
description = " · ".join(part for part in (team, player, status) if part)
if assists:
description += (" · ассистенты: " if language != "en" else " · assists: ") + ", ".join(assists)
return title, description, "goal"
if normalized in {"pn", "penalty", "pen"}:
minutes = _text(_first(row, ("penalty_time", "minutes", "duration")))
reason = _translated_penalty_reason(_first(row, ("penalty_reason", "reason", "description")), language)
team_penalty = player_number in {"", "0"} or _key(player_name) in {"teampenalty", "командныйштраф"}
title = "Командное удаление" if team_penalty and language != "en" else "Team penalty" if team_penalty else "Удаление" if language != "en" else "Penalty"
description = " · ".join(part for part in (team, "" if team_penalty else player, f"{minutes} мин" if minutes and language != "en" else f"{minutes} min" if minutes else "", reason) if part)
return title, description, "penalty"
if normalized == "bu":
skater = _text(_first(row, ("skater", "player", "pname")))
goalie = _text(_first(row, ("goalie", "goalkeeper")))
result = _text(_first(row, ("result", "status")))
if language != "en":
result = {"Goal": "Гол", "Saved": "Отражён", "Missed": "Мимо"}.get(result, result)
title = "Послематчевый буллит" if language != "en" else "Shootout attempt"
description = " · ".join(part for part in (team, skater, goalie, result) if part)
return title, description, "shootout"
if normalized == "ga":
status = _key(_first(row, ("status",)))
start = _text(_first(row, ("key",))) in {"0", "start"} or status.startswith("start")
title = ("Начало " if start else "Окончание ") + _period_title(period, language)
if language == "en":
title = ("Start of " if start else "End of ") + _period_title(period, language)
return title, "", "period"
if normalized == "gc":
goalie_a = _text(_first(row, ("gkA", "goalieA")))
goalie_b = _text(_first(row, ("gkB", "goalieB")))
title = "Изменение вратаря" if language != "en" else "Goalie change"
description = " · ".join(part for part in (goalie_a, goalie_b) if part) or team
return title, description, "goalie"
if normalized == "to":
return ("Тайм-аут" if language != "en" else "Timeout"), team, "timeout"
raw_text = _first(row, ("description", "desc", "text", "title", "event_name", "name"))
description = _translate_common_text(raw_text, language)
if normalized == "co":
return ("Комментарий" if language != "en" else "Comment"), description, "comment"
return _event_label(code, description, language), description, "info"
def _period_from_event_text(value: Any) -> str:
text = _text(value).casefold()
if "овертайм" in text or "overtime" in text:
return "4"
if "буллит" in text or "shootout" in text:
return "5"
match = re.search(r"(?:статистика|statistics).*?([123])(?:-|\s)*(?:го|st|nd|rd|th)?\s*(?:период|period)", text, re.I)
return match.group(1) if match else ""
def parse_match_events(
data: Mapping[str, Any],
*,
language: str = "ru",
home_ids: Iterable[Any] = (),
away_ids: Iterable[Any] = (),
home_names: Iterable[Any] = (),
away_names: Iterable[Any] = (),
) -> dict[str, Any]:
container = _find_container(data, ("events", "game_events", "match_events", "timeline", "play_by_play", "playbyplay"))
if container is None:
return {"available": False, "segments": [], "types": [], "items": []}
home_id_keys = _normalised_values(home_ids)
away_id_keys = _normalised_values(away_ids)
home_name_keys = _normalised_values(home_names)
away_name_keys = _normalised_values(away_names)
items: list[dict[str, Any]] = []
seen: set[tuple[str, ...]] = set()
context_period = ""
for source_index, (row, inherited_period, inherited_side) in enumerate(_flatten_rows(container, kind="event")):
event_time = _text(_first(row, ("time", "clock", "event_time", "game_time", "tm", "timecode")))
# Stat2TV records may contain both `code="pn"` and `type="Penalty"`.
# The compact event code is authoritative; choosing `type` first caused
# real penalties to be normalised as generic/other events in some feeds.
explicit_code = _text(_first(row, ("code", "event_code", "action_code", "kind_code")))
raw_type = _text(_first(row, ("type", "event_type", "event", "action", "kind", "action_type")))
code = explicit_code or raw_type
status = _text(_first(row, ("status", "pmStatus")))
period = _text(_first(row, ("period", "period_id", "period_number", "num_period", "p"))) or inherited_period
period_text = _first(row, ("description", "desc", "text", "title", "event_name", "name"))
text_period = _period_from_event_text(period_text)
if not text_period and _key(code) == "co" and "статистика матча" in _text(period_text).casefold():
text_period = "0"
inherited_context = "" if _key(code) == "co" else context_period
period = _period_from_key(period) or period or text_period or _infer_period(event_time, code, status) or inherited_context
if period and _key(code) != "co":
context_period = period
score = _text(_first(row, ("score", "score_value", "current_score")))
team_value = _first(row, ("team", "club", "team_info", "club_info"))
team_id, team_name, _ = _identity(team_value)
team_id = team_id or _text(_first(row, ("team_id", "id_team", "club_id", "id_club")))
team_name = team_name or _text(_first(row, ("team_name", "club_name", "team_title")))
player_value = _first(row, ("player", "athlete", "scorer", "author", "participant"))
player_id, player_name, player_number = _identity(player_value)
player_id = player_id or _text(_first(row, ("player_id", "id_player", "athlete_id", "scorer_id")))
player_name = player_name or _text(_first(row, ("pname", "player_name", "fullname", "full_name", "scorer_name", "author_name")))
player_number = player_number or _text(_first(row, ("pn", "player_number", "number", "jersey", "jersey_number")))
explicit_side = _side_from_key(_first(row, ("side", "team_side", "team"))) or inherited_side
side = _side_for_identity(
explicit_side=explicit_side,
team_id=team_id,
team_name=team_name,
home_ids=home_id_keys,
away_ids=away_id_keys,
home_names=home_name_keys,
away_names=away_name_keys,
)
title, description, category = _event_content(row, code, period, language)
penalty_reason = ""
penalty_minutes = ""
team_penalty = False
if category == "penalty":
penalty_reason = _translated_penalty_reason(
_first(row, ("penalty_reason", "reason", "description")),
language,
)
penalty_minutes = _text(_first(row, ("penalty_time", "minutes", "duration")))
team_penalty = player_number in {"", "0"} or _key(player_name) in {
"teampenalty",
"командныйштраф",
}
if not any((event_time, code, title, description, player_name, team_name, score)):
continue
signature = (period, event_time, code, description, team_id, team_name, player_id, player_name, player_number, score)
if signature in seen:
continue
seen.add(signature)
seconds = _game_seconds(event_time)
if _key(code) == "ga":
start = _text(_first(row, ("key",))) in {"0", "start"} or _key(status).startswith("start")
starts = {"0": -1, "1": 0, "2": 1200, "3": 2400, "4": 3600, "5": 3900}
ends = {"0": 4100, "1": 1200, "2": 2400, "3": 3600, "4": 3900, "5": 4000}
seconds = (starts if start else ends).get(period, seconds if seconds is not None else -1)
if seconds is None:
boundary = {"1": 1200, "2": 2400, "3": 3600, "4": 3900, "5": 4000, "0": 4100}.get(period, -1)
seconds = boundary - source_index / 1000 if boundary >= 0 else -source_index
items.append({
"id": _text(_first(row, ("id", "event_id", "external_id"))) or str(len(items) + 1),
"period": period,
"time": event_time,
"moscow_time": _text(_first(row, ("moscowtime", "created_at", "timestamp"))),
"code": code,
"type": raw_type or code,
"category": category,
"label": _event_label(code, description, language),
"title": title,
"description": description,
"score": score,
"side": side,
"team_id": team_id,
"team_name": team_name,
"player_id": player_id,
"player_name": player_name,
"player_number": player_number,
"penalty_reason": penalty_reason,
"penalty_minutes": penalty_minutes,
"team_penalty": team_penalty,
"sort_seconds": seconds,
"source_index": source_index,
})
if not items:
return {"available": False, "segments": [], "types": [], "items": []}
periods = sorted({item["period"] for item in items if item["period"] not in {"", "0"}}, key=_period_sort)
categories = [category for category in ("goal", "penalty", "shootout", "period", "timeout", "goalie", "comment", "info") if any(item["category"] == category for item in items)]
return {
"available": True,
"segments": ["all", *periods],
"types": ["all", *categories],
"items": items,
"count": len(items),
}
def _shot_coordinates(row: Mapping[str, Any]) -> tuple[float | None, float | None, str]:
x = _number(_first(row, ("x", "coord_x", "x_coord", "pos_x", "point_x", "shot_x", "sx")))
y = _number(_first(row, ("y", "coord_y", "y_coord", "pos_y", "point_y", "shot_y", "sy")))
if x is not None and y is not None:
return x, y, "xy"
for nested_key in ("coordinate", "coordinates", "coords", "point", "position", "location"):
nested = _first(row, (nested_key,))
if isinstance(nested, Mapping):
x = _number(_first(nested, ("x", "coord_x", "x_coord", "pos_x")))
y = _number(_first(nested, ("y", "coord_y", "y_coord", "pos_y")))
if x is not None and y is not None:
return x, y, "xy"
if isinstance(nested, (list, tuple)) and len(nested) >= 2:
x = _number(nested[0])
y = _number(nested[1])
if x is not None and y is not None:
return x, y, "array"
if isinstance(nested, str):
match = re.fullmatch(r"\s*(-?\d+(?:[.,]\d+)?)\s*[xх,:;]\s*(-?\d+(?:[.,]\d+)?)\s*", nested, re.I)
if match:
return float(match.group(1).replace(",", ".")), float(match.group(2).replace(",", ".")), "khl1000x500"
return None, None, ""
def parse_shots_map(
data: Mapping[str, Any],
*,
language: str = "ru",
home_ids: Iterable[Any] = (),
away_ids: Iterable[Any] = (),
home_names: Iterable[Any] = (),
away_names: Iterable[Any] = (),
) -> dict[str, Any]:
container = _find_container(data, ("shots_map", "shotsmap", "shot_map", "shotmap", "shot_coordinates", "shotcoordinates"))
if container is None:
return {"available": False, "segments": [], "players": [], "items": []}
home_id_keys = _normalised_values(home_ids)
away_id_keys = _normalised_values(away_ids)
home_name_keys = _normalised_values(home_names)
away_name_keys = _normalised_values(away_names)
items: list[dict[str, Any]] = []
seen: set[tuple[Any, ...]] = set()
for row, inherited_period, inherited_side in _flatten_rows(container, kind="shot"):
x, y, coordinate_system = _shot_coordinates(row)
if x is None or y is None:
continue
period = _text(_first(row, ("period", "period_id", "period_number", "num_period", "p"))) or inherited_period
period = _period_from_key(period) or period
event_time = _text(_first(row, ("time", "clock", "event_time", "game_time", "tm")))
stime = _number(_first(row, ("stime", "seconds", "game_seconds")))
team_value = _first(row, ("team", "club", "team_info", "club_info"))
team_id, team_name, _ = _identity(team_value)
team_id = team_id or _text(_first(row, ("team_id", "id_team", "club_id", "id_club")))
team_name = team_name or _text(_first(row, ("team_name", "club_name", "team_title")))
player_value = _first(row, ("player", "athlete", "shooter", "author"))
player_id, player_name, player_number = _identity(player_value)
player_id = player_id or _text(_first(row, ("player_id", "id_player", "athlete_id", "shooter_id")))
player_name = player_name or _text(_first(row, ("player_name", "fullname", "full_name", "shooter_name", "author_name")))
player_number = player_number or _text(_first(row, ("pn", "player_number", "number", "jersey", "jersey_number")))
explicit_side = _side_from_key(_first(row, ("side", "team_side", "team"))) or inherited_side
side = _side_for_identity(
explicit_side=explicit_side,
team_id=team_id,
team_name=team_name,
home_ids=home_id_keys,
away_ids=away_id_keys,
home_names=home_name_keys,
away_names=away_name_keys,
)
result_raw = _first(row, ("result", "shot_result", "outcome", "event", "type", "shot_type", "status"))
result = _text(result_raw)
numeric_result = _number(result_raw)
normalized_result = _key(result)
goal = numeric_result == 1 or _truthy(_first(row, ("goal", "is_goal", "scored", "g"))) or any(marker in normalized_result for marker in ("goal", "score", "гол"))
on_target_value = _first(row, ("on_target", "is_on_target", "sog", "target"))
on_target = numeric_result in {1, 2} or (_truthy(on_target_value) if on_target_value not in (None, "") else (goal or any(marker in normalized_result for marker in ("save", "saved", "ontarget", "sog", "вствор"))))
blocked = numeric_result == 4 or any(marker in normalized_result for marker in ("block", "blocked", "блок"))
missed = numeric_result == 3 or any(marker in normalized_result for marker in ("miss", "wide", "мимо"))
if goal:
result_label = "Goal" if language == "en" else "Гол"
elif blocked:
result_label = "Blocked" if language == "en" else "Заблокирован"
elif missed:
result_label = "Missed" if language == "en" else "Мимо"
elif on_target:
result_label = "On target" if language == "en" else "В створ"
else:
result_label = result or ("Shot" if language == "en" else "Бросок")
signature = (period, event_time, round(x, 5), round(y, 5), side, player_number, result)
if signature in seen:
continue
seen.add(signature)
items.append({
"id": _text(_first(row, ("id", "shot_id", "event_id", "external_id"))) or str(len(items) + 1),
"period": period,
"time": event_time,
"sort_seconds": int(stime) if stime is not None else (_game_seconds(event_time) or 0),
"x": x,
"y": y,
"coordinate_system": coordinate_system,
"rink_width": 1000 if coordinate_system == "khl1000x500" else None,
"rink_height": 500 if coordinate_system == "khl1000x500" else None,
"attacking_left": _text(_first(row, ("leftAB", "left_team", "attack_left"))),
"side": side,
"team_id": team_id,
"team_name": team_name,
"player_id": player_id,
"player_name": player_name,
"player_number": player_number,
"result": result,
"result_code": int(numeric_result) if numeric_result is not None else None,
"result_label": result_label,
"goal": goal,
"on_target": on_target,
"blocked": blocked,
"missed": missed,
})
if not items:
return {"available": False, "segments": [], "players": [], "items": []}
periods = sorted({item["period"] for item in items if item["period"]}, key=_period_sort)
player_map: dict[str, dict[str, Any]] = {}
for item in items:
identifier = item["player_id"] or f"{item['side']}:{item['player_number']}" or _key(item["player_name"])
if not identifier:
continue
player_map.setdefault(identifier, {
"id": item["player_id"],
"name": item["player_name"],
"number": item["player_number"],
"side": item["side"],
"team_id": item["team_id"],
"team_name": item["team_name"],
})
players = sorted(player_map.values(), key=lambda item: (0 if item.get("side") == "home" else 1, _key(item.get("name")), _key(item.get("number"))))
return {
"available": True,
"segments": ["all", *periods],
"players": players,
"items": items,
"count": len(items),
"coordinate_system": "khl1000x500" if any(item.get("coordinate_system") == "khl1000x500" for item in items) else "auto",
"rink_width": 1000,
"rink_height": 500,
}