This commit is contained in:
2026-08-19 15:08:39 +03:00
parent 478557f3ad
commit a03c54dbe8
5148 changed files with 31553313 additions and 0 deletions

View File

@@ -0,0 +1,644 @@
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,
}