Files
hockey_new/khl_site/person_data.py
2026-09-27 00:25:40 +03:00

222 lines
11 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.

"""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}