222 lines
11 KiB
Python
222 lines
11 KiB
Python
"""Validated names and presentation data for both old and new KHL exports.
|
||
|
||
This module is deliberately independent of FastAPI and network clients. Lists and
|
||
cards use the same names; staff statistics are derived from personal tables, never
|
||
from the site's navigation, footer, season selector or global club list.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from typing import Any
|
||
|
||
|
||
def text(value: Any) -> str:
|
||
return re.sub(r"\s+", " ", str(value or "")).strip()
|
||
|
||
|
||
def valid_name(value: Any, language: str = "ru") -> bool:
|
||
name = text(value)
|
||
if name.casefold() in {"имя не загружено", "без имени"}:
|
||
return False
|
||
if not 3 <= len(name) <= 100:
|
||
return False
|
||
if re.search(r"конференц|дивизион|статистик|хоккейная|дата рождения|гражданство|контракт", name, re.I):
|
||
return False
|
||
blocked = {"игрок", "игроки", "тренеры", "судьи", "сезон", "матчи", "новости", "команда", "клуб", "амплуа",
|
||
"khl", "league", "conference", "statistics", "players", "coaches", "officials", "hockey", "news"}
|
||
if set(name.casefold().split()) & blocked:
|
||
return False
|
||
letters = "А-Яа-яЁё" if language == "ru" else "A-Za-zÀ-ž"
|
||
return bool(re.fullmatch(rf"[{letters}][{letters}'’−-]*(?:\s+(?:[{letters}][{letters}'’−-]*|[{letters}]\.)){{1,4}}", name))
|
||
|
||
|
||
def surname_first(value: Any) -> str:
|
||
parts = text(value).split()
|
||
return " ".join((parts[1], parts[0])) if len(parts) >= 2 else ""
|
||
|
||
|
||
def player_name(item: dict[str, Any], language: str = "ru") -> str:
|
||
identity = item.get("identity" if language == "ru" else "identity_en") or {}
|
||
profile = item.get("profile") or {}
|
||
# A list column has a known order: surname, given name, optional patronymic.
|
||
listed = surname_first(profile.get(f"list_name_{language}"))
|
||
structured = " ".join(filter(None, (text(identity.get("first_name")), text(identity.get("last_name")))))
|
||
candidates = [listed, structured, identity.get("full_name"), profile.get("name" if language == "ru" else "name_en")]
|
||
if language == "en":
|
||
candidates.append((item.get("profile_en") or {}).get("name"))
|
||
for candidate in candidates:
|
||
if valid_name(candidate, language):
|
||
return text(candidate)
|
||
# No invented personal name when the old file contains only menu text.
|
||
return "Имя не загружено" if language == "ru" else ""
|
||
|
||
|
||
def staff_name(item: dict[str, Any], language: str = "ru") -> str:
|
||
profile = item.get("profile") or {}
|
||
local = (item.get("languages") or {}).get(language) or {}
|
||
candidates = [" ".join(filter(None, (text(item.get(f"first_name_{language}")), text(item.get(f"last_name_{language}")))))]
|
||
for source in (local, profile if language == "ru" else {}):
|
||
parts = source.get("name_parts") or {}
|
||
candidates.append(" ".join(filter(None, (text(parts.get("first_name")), text(parts.get("last_name"))))))
|
||
for candidate in candidates:
|
||
if valid_name(candidate, language):
|
||
return candidate
|
||
raw = [item.get(f"name_{language}"), local.get("name")]
|
||
if language == "ru":
|
||
raw += [profile.get("name"), item.get("name")]
|
||
title = text(profile.get("page_title"))
|
||
raw.append(re.split(r",\s*(?:тренер|судья|арбитр)\b", title, maxsplit=1, flags=re.I)[0])
|
||
else:
|
||
# EN profile text is normally already in given-name/surname order.
|
||
candidate = profile.get("name_en")
|
||
if valid_name(candidate, "en"):
|
||
return text(candidate)
|
||
for candidate in raw:
|
||
if valid_name(candidate, language):
|
||
return surname_first(candidate)
|
||
return "Имя не загружено" if language == "ru" else ""
|
||
|
||
|
||
def season_name(value: Any) -> str:
|
||
"""Accept consecutive seasons, rejecting copyright 2008–2026 and dates."""
|
||
value = text(value)
|
||
match = re.search(r"(?<!\d)((?:19|20)\d{2})\s*[/–—-]\s*((?:19|20)?\d{2})(?!\d)", value)
|
||
if match:
|
||
left, right = match.groups()
|
||
start = int(left)
|
||
end = int(right) if len(right) == 4 else (start // 100) * 100 + int(right)
|
||
if end < start and len(right) == 2:
|
||
end += 100
|
||
else:
|
||
match = re.search(r"(?<![\d/.-])(\d{2})\s*[/–—-]\s*(\d{2})(?![\d/.-])", value)
|
||
if not match:
|
||
return ""
|
||
start, end = (2000 + int(x) for x in match.groups())
|
||
return f"{start}/{end}" if 2008 <= start <= 2099 and end == start + 1 else ""
|
||
|
||
|
||
def stage_name(value: Any) -> str:
|
||
low = text(value).casefold()
|
||
if "надежд" in low or "hope" in low:
|
||
return "Кубок Надежды"
|
||
if "плей" in low or "playoff" in low or "play-off" in low:
|
||
return "Плей-офф"
|
||
if "рег" in low or "regular" in low:
|
||
return "Регулярный чемпионат"
|
||
return "КХЛ"
|
||
|
||
|
||
def number(value: Any) -> int | float | None:
|
||
value = text(value).replace("−", "-").replace(",", ".").replace("%", "")
|
||
if not re.fullmatch(r"-?\d+(?:\.\d+)?", value):
|
||
return None
|
||
n = float(value)
|
||
return int(n) if n.is_integer() else n
|
||
|
||
|
||
def official_role(value: Any) -> str:
|
||
low = text(value).casefold()
|
||
if "линей" in low or low == "лн." or "linesman" in low:
|
||
return "Линейный судья"
|
||
if "главн" in low or low == "гл." or low == "referee":
|
||
return "Главный судья"
|
||
return ""
|
||
|
||
|
||
COACH_KEYS = ("games", "wins", "win_ot", "win_so", "loss_so", "loss_ot", "losses", "points", "points_pct")
|
||
PENALTY_KEYS = ("minor", "major", "misconduct", "game_misconduct", "match_penalty", "penalty_shots", "team_minutes", "goalie_minutes")
|
||
|
||
|
||
def _coach_stats(values: list[Any], headers: list[Any]) -> dict[str, Any] | None:
|
||
# The legacy export preserved the 3 subcolumns (total/home/away), but lost
|
||
# their parent headers. Only accept the known table and exact row width.
|
||
if len(values) == 28 and sum(text(x) == "Всего" for x in headers) >= 8:
|
||
result = {key: number(values[1 + i * 3]) for i, key in enumerate(COACH_KEYS)}
|
||
result["home"] = {key: number(values[2 + i * 3]) for i, key in enumerate(COACH_KEYS)}
|
||
result["away"] = {key: number(values[3 + i * 3]) for i, key in enumerate(COACH_KEYS)}
|
||
elif len(values) == 10 and {"И", "ВО", "ВБ", "%О"}.issubset(set(map(text, headers))):
|
||
result = {key: number(values[i + 1]) for i, key in enumerate(COACH_KEYS)}
|
||
else:
|
||
return None
|
||
if result.get("games") is None:
|
||
return None
|
||
for scope in (result, result.get("home", {}), result.get("away", {})):
|
||
for target, keys in (("wins_total", ("wins", "win_ot", "win_so")), ("losses_total", ("losses", "loss_ot", "loss_so"))):
|
||
vals = [scope.get(key) for key in keys]
|
||
if all(v is not None for v in vals):
|
||
scope[target] = sum(vals)
|
||
if scope.get("games") and scope.get("wins_total") is not None:
|
||
scope["wins_pct"] = round(100 * scope["wins_total"] / scope["games"], 1)
|
||
return result
|
||
|
||
|
||
def _official_stats(values: list[Any], headers: list[Any]) -> dict[str, Any] | None:
|
||
if len(values) != 26 or "Амп" not in headers or "И" not in headers:
|
||
return None
|
||
stats = {"games": number(values[3]), "penalty_minutes": number(values[4]), "penalty_avg": number(values[7])}
|
||
if stats["games"] is None:
|
||
return None
|
||
stats["home"] = {"penalty_minutes": number(values[5]), "penalty_avg": number(values[8])}
|
||
stats["away"] = {"penalty_minutes": number(values[6]), "penalty_avg": number(values[9])}
|
||
for i, key in enumerate(PENALTY_KEYS):
|
||
home, away = number(values[10 + i * 2]), number(values[11 + i * 2])
|
||
stats[key] = home + away if home is not None and away is not None else None
|
||
stats["home"][key], stats["away"][key] = home, away
|
||
return stats
|
||
|
||
|
||
def staff_view(item: dict[str, Any], kind: str) -> dict[str, Any]:
|
||
profile = item.get("profile") or (item.get("languages") or {}).get("ru") or {}
|
||
rows, totals, matches, seen = [], [], [], set()
|
||
unparsed = 0
|
||
for table in profile.get("tables") or []:
|
||
headers = table.get("headers") or []
|
||
for raw in table.get("rows") or []:
|
||
values = raw.get("values") or []
|
||
if not values:
|
||
continue
|
||
label, section = text(values[0]), text(raw.get("section"))
|
||
if kind == "officials" and len(values) == 24 and re.fullmatch(r"\d{4}-\d{2}-\d{2}", label):
|
||
key = ("match", *map(text, values))
|
||
if key not in seen:
|
||
seen.add(key)
|
||
matches.append({"date": label, "teams": text(values[1]), "score": text(values[2]),
|
||
"role": official_role(values[4]), "penalty_minutes": number(values[5]),
|
||
"home_minutes": number(values[6]), "away_minutes": number(values[7])})
|
||
continue
|
||
season = season_name(section) if kind == "coaches" else season_name(label)
|
||
is_total = not season and ("суммар" in section.casefold() or label.casefold().startswith(("всего", "регулярный чемпионат:", "плей-офф:", "кубок надежды:")))
|
||
if not season and not is_total:
|
||
continue
|
||
stats = _coach_stats(values, headers) if kind == "coaches" else _official_stats(values, headers)
|
||
if stats is None:
|
||
unparsed += 1
|
||
continue
|
||
role = official_role(values[2]) if kind == "officials" else "Тренер"
|
||
record = {"season": season, "stage": stage_name(section if kind == "coaches" and season else label),
|
||
"team": label if kind == "coaches" and season else "", "role": role,
|
||
"number": text(values[1]) if kind == "officials" else "", "stats": stats}
|
||
if is_total:
|
||
record["label"] = "Всего в КХЛ" if label.casefold().startswith("всего") else stage_name(label)
|
||
key = (season, record["stage"], record["team"], role, record.get("label", ""), *map(text, values[1:]))
|
||
if key not in seen:
|
||
seen.add(key)
|
||
(totals if is_total else rows).append(record)
|
||
rows.sort(key=lambda r: (r["season"], r["stage"] == "Плей-офф", r["team"]), reverse=True)
|
||
fields = profile.get("fields") or {}
|
||
facts = []
|
||
for label, keys in (("Дата рождения", ("Дата рождения", "birthday", "birth_date")),
|
||
("Гражданство", ("Гражданство", "country")),
|
||
("Место рождения", ("Место рождения", "birth_place"))):
|
||
value = next((fields[k] for k in keys if fields.get(k)), "")
|
||
if isinstance(value, (str, int, float)) and len(str(value)) < 130:
|
||
facts.append({"label": label, "value": value})
|
||
roles = sorted({r["role"] for r in rows if r["role"]})
|
||
if kind == "officials" and not roles:
|
||
role = official_role(fields.get("Амплуа") or fields.get("role"))
|
||
roles = [role] if role else []
|
||
return {"rows": rows, "totals": totals, "matches": matches, "facts": facts,
|
||
"seasons": sorted({r["season"] for r in rows}, reverse=True),
|
||
"teams": sorted({r["team"] for r in rows if r["team"]}), "roles": roles,
|
||
"unparsed_rows": unparsed}
|