Files
hockey_new/khl_site/khl_data_center.py
2026-08-19 15:08:39 +03:00

1416 lines
94 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.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
import argparse
import json
import math
import os
import re
import subprocess
import sys
import threading
import time
import webbrowser
from collections import Counter
from datetime import datetime
from pathlib import Path
from typing import Any
import requests as std_requests
from bs4 import BeautifulSoup
try:
from curl_cffi import requests as curl_requests
except Exception:
curl_requests = None
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import HTMLResponse
import uvicorn
ROOT = Path(__file__).resolve().parent
DATA_DIR = ROOT / "data"
PLAYERS_FILE = DATA_DIR / "khl_players_all.json"
COACHES_FILE = DATA_DIR / "khl_coaches_all.json"
OFFICIALS_FILE = DATA_DIR / "khl_officials_all.json"
WEB_DIR = ROOT / "web"
INDEX_FILE = WEB_DIR / "index.html"
PLAYER_SITE_CACHE_FILE = DATA_DIR / "khl_player_site_profiles.json"
PLAYER_MATCHES_CACHE_DIR = DATA_DIR / "player_matches_cache"
PLAYER_SITE_CACHE_LOCK = threading.RLock()
_PLAYER_SITE_CACHE: dict[str, Any] | None = None
_PLAYER_SITE_FAILURES: dict[str, float] = {}
APP = FastAPI(title="KHL Data Center", version="2.5.1")
HTML = r'''<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>KHL Data Center</title>
<style>
:root{
color-scheme:dark;
--bg:#080b11;
--panel:#101620;
--panel-2:#151d2a;
--panel-3:#1a2433;
--line:#293548;
--line-soft:#202a39;
--text:#edf3ff;
--muted:#8e9bb0;
--accent:#6aa8ff;
--accent-strong:#3e8ef7;
--good:#51d692;
--warn:#f2bd68;
--danger:#ff6f7e;
--sticky:#111925;
--sticky-alt:#151e2c;
}
*{box-sizing:border-box}
html,body{min-height:100%}
body{margin:0;font-family:Inter,Segoe UI,Arial,sans-serif;background:var(--bg);color:var(--text)}
button,input,select{font:inherit}
button,input,select{border:1px solid var(--line);border-radius:9px;background:var(--panel-2);color:var(--text)}
button{padding:9px 13px;cursor:pointer}
button:hover{border-color:var(--accent)}
button:disabled{opacity:.45;cursor:default}
input,select{padding:10px;width:100%}
a{color:var(--accent)}
header{height:66px;display:flex;align-items:center;justify-content:space-between;padding:0 18px;background:var(--panel);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:30}
.brand{font-size:19px;font-weight:850}.small{font-size:12px;color:var(--muted)}.actions{display:flex;gap:7px;flex-wrap:wrap;justify-content:flex-end}
.layout{display:grid;grid-template-columns:390px minmax(0,1fr);min-height:calc(100vh - 66px)}
aside{background:var(--panel);border-right:1px solid var(--line);min-width:0;position:sticky;top:66px;height:calc(100vh - 66px)}
.tabs{display:flex;gap:6px;padding:12px}.tabs button{flex:1}.tabs button.active{background:var(--accent);color:#07111e;border-color:var(--accent);font-weight:850}
.filters{padding:0 12px 12px;display:grid;gap:8px;border-bottom:1px solid var(--line)}.filter-row{display:grid;grid-template-columns:1fr 1fr;gap:8px}.list-head{padding:10px 13px;display:flex;justify-content:space-between}.list{height:calc(100vh - 284px);overflow:auto}.row{display:grid;grid-template-columns:48px minmax(0,1fr) auto;gap:10px;align-items:center;padding:10px 12px;border-top:1px solid rgba(43,53,72,.62);cursor:pointer}.row:hover{background:var(--panel-2)}.row.active{background:linear-gradient(90deg,rgba(106,168,255,.17),var(--panel-2));box-shadow:inset 3px 0 0 var(--accent)}.row.active .name{color:#fff}.avatar{width:46px;height:46px;border-radius:50%;object-fit:cover;background:#222c3c}.name{font-weight:780;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.name-en{font-size:11px;color:var(--muted);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.badges{display:flex;gap:4px;flex-wrap:wrap;margin-top:4px}.badge{font-size:10px;padding:2px 6px;border-radius:999px;background:#28344a;color:#d2dcf0}.badge.good{color:var(--good);background:rgba(81,214,146,.12)}.pager{display:flex;gap:8px;align-items:center;justify-content:center;padding:10px;border-top:1px solid var(--line)}
main{padding:18px;min-width:0;background:linear-gradient(180deg,#0b1018 0%,#090d14 100%)}.empty{min-height:75vh;display:grid;place-items:center;color:var(--muted);font-size:18px}
.profile-card{background:linear-gradient(135deg,#121a27 0%,#0e141e 100%);border:1px solid var(--line);border-radius:15px;padding:24px;box-shadow:0 16px 40px rgba(0,0,0,.24);margin-bottom:18px}.profile-head{display:grid;grid-template-columns:158px minmax(0,1fr);gap:24px;align-items:center}.profile-photo-wrap{display:flex;flex-direction:column;align-items:center;gap:11px}.profile-photo{width:158px;height:158px;border-radius:15px;object-fit:cover;background:#202a39;border:1px solid #344056}.profile-type{padding:7px 11px;border-radius:999px;background:#c81f32;color:#fff;font-size:11px;font-weight:850;letter-spacing:.25px}.profile-title-row{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap}.profile-title{margin:0;font-size:31px;line-height:1.15;color:#fff}.profile-title-en{font-size:17px;color:#b4c2d7;font-weight:650}.profile-subtitle{margin-top:9px;font-size:17px;color:#b4c2d7}.profile-roleline{display:flex;flex-wrap:wrap;gap:14px;align-items:center;margin-top:17px;font-size:18px;font-weight:750}.profile-roleline .sep{width:1px;height:26px;background:#38465c}.profile-linkline{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}.profile-link{display:inline-flex;align-items:center;gap:6px;padding:7px 11px;border:1px solid #34445c;border-radius:999px;background:#162131;text-decoration:none;font-size:12px;font-weight:750}.profile-link:hover{background:#1b2a40;border-color:var(--accent)}
.fact-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(135px,1fr));margin-top:21px;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.fact{padding:13px 16px;border-right:1px solid var(--line);min-width:0}.fact:last-child{border-right:0}.fact label{display:block;color:var(--muted);font-size:11px;margin-bottom:5px}.fact div{font-weight:800;color:#fff;overflow-wrap:anywhere}.fact img{width:18px;height:18px;border-radius:50%;vertical-align:-4px;margin-right:6px}
/* Компактная карточка игрока: максимум данных без пустых вертикальных зон. */
.profile-card.compact-player{padding:13px 14px 12px;margin-bottom:9px;border-radius:13px;box-shadow:0 10px 26px rgba(0,0,0,.20)}
.profile-card.compact-player .profile-head{grid-template-columns:112px minmax(0,1fr);gap:14px;align-items:stretch}
.profile-card.compact-player .profile-photo-wrap{gap:6px;justify-content:flex-start}
.profile-card.compact-player .profile-photo{width:112px;height:126px;border-radius:11px}
.profile-card.compact-player .profile-type{width:100%;padding:5px 7px;border-radius:8px;font-size:10px;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.profile-card.compact-player .profile-content{display:flex;flex-direction:column;min-width:0}
.profile-card.compact-player .profile-title-row{gap:9px;align-items:center;min-height:30px}
.profile-card.compact-player .profile-title{font-size:24px;line-height:1.05;letter-spacing:-.25px}
.profile-card.compact-player .profile-title-en{font-size:13px;line-height:1.1;color:#9eacc1}
.profile-card.compact-player .profile-roleline{gap:9px;margin-top:4px;font-size:13px;font-weight:750;color:#d8e2f1}
.profile-card.compact-player .profile-roleline .sep{height:15px}
.profile-card.compact-player .fact-grid{grid-template-columns:repeat(6,minmax(88px,1fr));gap:5px;margin-top:8px;border:0}
.profile-card.compact-player .fact{padding:6px 8px;border:1px solid var(--line-soft);border-radius:7px;background:rgba(20,29,42,.76);min-height:43px}
.profile-card.compact-player .fact label{font-size:9px;margin-bottom:3px;line-height:1.05;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.profile-card.compact-player .fact div{font-size:12px;line-height:1.15;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.profile-card.compact-player .fact img{width:15px;height:15px;vertical-align:-3px;margin-right:4px}
.profile-card.compact-player .profile-linkline{gap:5px;margin-top:6px}
.profile-card.compact-player .profile-link{padding:4px 8px;border-radius:7px;font-size:10px}
.section{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:19px 0 9px}.section h2{font-size:18px;margin:0}.notice{padding:10px 12px;border:1px solid rgba(106,168,255,.3);background:rgba(106,168,255,.08);border-radius:10px;color:#cfdef3;font-size:12px}.player-tabs{display:flex;gap:7px;margin:0 0 14px;padding:6px;background:#0f1621;border:1px solid var(--line);border-radius:12px;position:sticky;top:76px;z-index:12}.player-tab{padding:9px 16px;background:transparent;border-color:transparent;color:var(--muted);font-weight:800}.player-tab:hover{color:#fff;border-color:#34445c}.player-tab.active{background:var(--accent);border-color:var(--accent);color:#07111e}.matches-state{padding:24px;border:1px solid var(--line);border-radius:12px;background:var(--panel);color:var(--muted);text-align:center}.matches-pager{display:flex;align-items:center;justify-content:center;gap:10px;padding:11px 12px;border-top:1px solid var(--line);background:#111925}.matches-pager .small{min-width:190px;text-align:center}.match-link{color:#9bc5ff;text-decoration:none;font-weight:750}.match-link:hover{text-decoration:underline}
.table-wrap{overflow:auto;border:1px solid var(--line);border-radius:12px;background:var(--panel);margin-bottom:13px;max-width:100%;scrollbar-color:#47566f #111824}.table-title{padding:11px 13px;background:var(--panel-2);border-bottom:1px solid var(--line);font-size:13px;font-weight:850}.table-note{padding:9px 12px;color:var(--muted);font-size:11px;border-top:1px solid var(--line)}
.khl-table{width:max-content;min-width:100%;border-collapse:separate;border-spacing:0;background:var(--panel)}.khl-table th,.khl-table td{padding:9px 11px;border-bottom:1px solid var(--line-soft);font-size:12px;vertical-align:middle;white-space:nowrap;color:var(--text)}.khl-table th{position:sticky;top:0;z-index:4;background:#182130;color:#99a7bb;text-align:center;font-weight:800}.khl-table td{text-align:center;font-variant-numeric:tabular-nums}.khl-table tbody tr:nth-child(even){background:#121a26}.khl-table tbody tr:hover{background:#192436}.khl-table tbody tr:last-child td{border-bottom:0}.khl-table th:first-child,.khl-table td:first-child{position:sticky;left:0;z-index:5;text-align:left;min-width:205px;max-width:320px;white-space:normal;box-shadow:8px 0 12px -12px #000}.khl-table th:first-child{z-index:8;background:#1b2636}.khl-table td:first-child{background:var(--sticky);font-weight:750}.khl-table tbody tr:nth-child(even) td:first-child{background:var(--sticky-alt)}.khl-table tbody tr:hover td:first-child{background:#1b2738}.khl-table .number{text-align:right}.khl-table .muted-cell{color:var(--muted)}.khl-table .team{display:block;color:#69adff;margin-top:5px;font-weight:600}.khl-table .season{display:block;color:#fff;font-weight:850}.khl-table abbr{text-decoration:none;border-bottom:1px dotted #6f7e93;cursor:help}.khl-table .total-row td{font-weight:850;background:#172333}.khl-table .total-row td:first-child{background:#1c2a3d}.rows-table{width:100%;border-collapse:collapse}.rows-table td{padding:9px 12px;border-bottom:1px solid var(--line-soft);vertical-align:top}.rows-table td:first-child{color:var(--muted);width:31%}.rows-table td:last-child{font-weight:650;overflow-wrap:anywhere}.rows-table tr:last-child td{border-bottom:0}.team-history-cell{display:flex;align-items:center;gap:10px;min-width:190px}.team-history-logo{width:36px;height:36px;border-radius:8px;object-fit:contain;background:#202b3b;border:1px solid #344157;padding:3px;flex:0 0 auto}.team-history-name{font-weight:850;color:#fff}.season-tags{display:flex;flex-wrap:wrap;gap:5px;max-width:440px}.season-tag{display:inline-flex;padding:3px 7px;border:1px solid #33435a;border-radius:999px;background:#172233;color:#c9d7ea;font-size:10px;font-weight:700}
.chipbar{display:flex;flex-wrap:wrap;gap:6px}.chip{padding:4px 8px;border:1px solid #30405a;border-radius:999px;background:#162131;font-size:11px;color:#c7d3e5}.flag{width:21px;height:21px;border-radius:50%;vertical-align:-5px;margin-right:6px}.flag-emoji{display:inline-flex;align-items:center;justify-content:center;width:24px;height:20px;margin-right:6px;font-size:18px;line-height:1;vertical-align:-2px}.job{position:fixed;right:16px;bottom:16px;width:min(520px,calc(100vw - 32px));background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px;box-shadow:0 12px 40px rgba(0,0,0,.45);display:none;z-index:50}.job.show{display:block}.progress{height:6px;background:#273147;border-radius:999px;overflow:hidden;margin-top:8px}.progress span{display:block;height:100%;background:var(--accent);width:35%;animation:move 1.1s linear infinite}@keyframes move{from{transform:translateX(-110%)}to{transform:translateX(300%)}}
@media(max-width:1200px){.profile-card.compact-player .fact-grid{grid-template-columns:repeat(4,minmax(92px,1fr))}}
@media(max-width:900px){header{height:auto;padding:10px;gap:8px;align-items:flex-start;flex-direction:column}.actions{justify-content:flex-start}.layout{grid-template-columns:1fr}aside{position:static;height:auto}.list{height:380px}.profile-head{grid-template-columns:105px 1fr}.profile-photo{width:105px;height:105px}.profile-title{font-size:24px}.fact-grid{grid-template-columns:repeat(2,minmax(120px,1fr))}.profile-card.compact-player .profile-head{grid-template-columns:92px minmax(0,1fr);gap:10px}.profile-card.compact-player .profile-photo{width:92px;height:104px}.profile-card.compact-player .profile-title{font-size:20px}.profile-card.compact-player .profile-title-en{font-size:12px}.profile-card.compact-player .fact-grid{grid-template-columns:repeat(3,minmax(92px,1fr))}}
@media(max-width:600px){.profile-card.compact-player .profile-head{grid-template-columns:1fr}.profile-card.compact-player .profile-photo-wrap{display:grid;grid-template-columns:72px minmax(0,1fr);align-items:center}.profile-card.compact-player .profile-photo{width:72px;height:82px}.profile-card.compact-player .profile-type{width:auto}.profile-card.compact-player .fact-grid{grid-template-columns:repeat(2,minmax(100px,1fr))}}
.primary-action{background:var(--accent)!important;border-color:var(--accent)!important;color:#06111f!important;font-weight:900}</style>
</head>
<body>
<header>
<div><div class="brand">KHL Data Center</div><div class="small" id="statusLine">Загрузка данных…</div></div>
<div class="actions">
<button class="primary-action" onclick="collectData('all')">KHL.ru: обновить всё</button>
<button onclick="collectData('players')">Игроки</button>
<button onclick="collectData('coaches')">Тренеры</button>
<button onclick="collectData('officials-current')">Судьи: текущие</button>
<button onclick="collectData('officials-history')">Судьи: все</button>
<button onclick="reloadData()">Перечитать JSON</button>
</div>
</header>
<div class="layout">
<aside>
<div class="tabs">
<button id="tab-players" class="active" onclick="setKind('players')">Игроки</button>
<button id="tab-coaches" onclick="setKind('coaches')">Тренеры</button>
<button id="tab-officials" onclick="setKind('officials')">Судьи</button>
</div>
<div class="filters">
<input id="search" placeholder="Имя, фамилия, English name или ID" oninput="debouncedLoad()">
<div class="filter-row">
<select id="season" onchange="loadList(true)"><option value="">Все сезоны</option></select>
<select id="extra" onchange="loadList(true)"><option value="">Все</option></select>
</div>
</div>
<div class="list-head"><span id="total">0 записей</span><span class="small">по 100</span></div>
<div class="list" id="list"></div>
<div class="pager"><button id="prev" onclick="changePage(-1)">←</button><span class="small" id="pageInfo">1 / 1</span><button id="next" onclick="changePage(1)">→</button></div>
</aside>
<main><div id="detail" class="empty">Выбери запись слева</div></main>
</div>
<div class="job" id="job"><b id="jobTitle">Обновление</b><div class="small" id="jobMessage"></div><div class="progress" id="jobProgress"><span></span></div></div>
<script>
const UI_STORAGE_KEY='khlDataCenterUiStateV1';
function loadUiState(){try{return JSON.parse(localStorage.getItem(UI_STORAGE_KEY)||'{}')||{}}catch(e){return {}}}
const UI=loadUiState();
const KINDS=['players','coaches','officials'];
const defaultMap=()=>({players:null,coaches:null,officials:null});
const defaultPages=()=>({players:1,coaches:1,officials:1});
const defaultScroll=()=>({players:0,coaches:0,officials:0});
const S={
kind:KINDS.includes(UI.kind)?UI.kind:'players',
page:1,
pages:1,
selectedByKind:{...defaultMap(),...(UI.selectedByKind||{})},
pageByKind:{...defaultPages(),...(UI.pageByKind||{})},
scrollByKind:{...defaultScroll(),...(UI.scrollByKind||{})},
status:null,
timer:null,
openSeq:0,
currentPlayer:null,
currentPlayerId:null,
playerTab:'overview',
matchesPage:1,
itemCache:new Map(),
extraCache:new Map(),
matchesCache:new Map(),
extraTimer:null
};
S.page=Math.max(1,Number(S.pageByKind[S.kind])||1);
function selectedId(kind=S.kind){const v=S.selectedByKind[kind];return v===null||v===undefined||v===''?null:String(v)}
function saveUiState(){try{localStorage.setItem(UI_STORAGE_KEY,JSON.stringify({kind:S.kind,selectedByKind:S.selectedByKind,pageByKind:S.pageByKind,scrollByKind:S.scrollByKind}))}catch(e){}}
function captureListState(){const list=$('list');if(!list)return;S.pageByKind[S.kind]=S.page;S.scrollByKind[S.kind]=list.scrollTop||0;saveUiState()}
function markActiveRow(id=selectedId()){document.querySelectorAll('#list .row').forEach(row=>row.classList.toggle('active',id!==null&&row.dataset.id===String(id)))}
function restoreListScroll(kind=S.kind){requestAnimationFrame(()=>{if(kind!==S.kind)return;const list=$('list');if(!list)return;list.scrollTop=Math.max(0,Number(S.scrollByKind[kind])||0);markActiveRow(selectedId(kind))})}
const $=id=>document.getElementById(id);
const esc=v=>String(v??'').replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;').replaceAll('"','&quot;').replaceAll("'",'&#039;');
const SKATER_ORDER=['gp','g','a','pts','pm','plus','minus','pim','g_even','g_pp','g_sh','g_ot','gwg','sds','shots','shot_pct','shots_avg','faceoffs','faceoff_wins','faceoff_pct','toi_avg','sft_avg','toi_even_avg','sft_even_avg','toi_pp_avg','sft_pp_avg','toi_sh_avg','sft_sh_avg','hits','blocks','fouls_against','takeaways','interceptions'];
const GOALIE_ORDER=['gp','w','l','shootout_games','shots_against','ga','sv','sv_pct','gaa','g','a','so','pim','toi'];
const SHORT={gp:'И',g:'Ш',a:'А',pts:'О',pm:'+/-',plus:'+',minus:'-',pim:'Штр',g_even:'ШР',g_pp:'ШБ',g_sh:'ШМ',g_ot:'ШО',gwg:'ШП',sds:'РБ',shots:'БВ',shot_pct:'%БВ',shots_avg:'БВ/И',faceoffs:'Вбр',faceoff_wins:'ВВбр',faceoff_pct:'%Вбр',toi_avg:'ВП/И',sft_avg:'См/И',toi_even_avg:'ВПР/И',sft_even_avg:'СмР/И',toi_pp_avg:'ВПБ/И',sft_pp_avg:'СмБ/И',toi_sh_avg:'ВПМ/И',sft_sh_avg:'СмМ/И',hits:'СПр',blocks:'БлБ',fouls_against:'ФоП',takeaways:'ОТБ',interceptions:'ПХТ',w:'В',l:'П',shootout_games:'ИБ',shots_against:'Бр',ga:'ПШ',sv:'ОБ',sv_pct:'%ОБ',gaa:'КН',so:'И"0"',toi:'ВП'};
const STAT_HINTS={
'И':'Количество проведённых игр','Ш':'Заброшенные шайбы','А':'Передачи','О':'Очки','+/-':'Плюс/Минус','+':'Плюс','-':'Минус','Штр':'Штрафное время',
'ШР':'Шайбы в равенстве','ШБ':'Шайбы в большинстве','ШМ':'Шайбы в меньшинстве','ШО':'Шайбы в овертайме','ШП':'Победные шайбы','РБ':'Решающие буллиты',
'БВ':'Броски по воротам','%БВ':'Процент реализованных бросков','БВ/И':'Среднее количество бросков по воротам за игру','Вбр':'Вбрасывания','ВВбр':'Выигранные вбрасывания','%Вбр':'Процент выигранных вбрасываний',
'ВП/И':'Среднее время на площадке за игру','См/И':'Среднее количество смен за игру','ВПР/И':'Среднее время на площадке при игре в равных составах за игру','СмР/И':'Среднее количество смен при игре в равных составах за игру',
'ВПБ/И':'Среднее время на площадке при игре в большинстве за игру','СмБ/И':'Среднее количество смен при игре в большинстве за игру','ВПМ/И':'Среднее время на площадке при игре в меньшинстве за игру','СмМ/И':'Среднее количество смен при игре в меньшинстве за игру',
'СПр':'Силовые приёмы','БлБ':'Блокированные броски','ФоП':'Фолы против','ОТБ':'Отборы шайбы','ПХТ':'Перехваты передач',
'В':'Выигрыши','П':'Проигрыши','ИБ':'Игры с буллитными сериями','Бр':'Броски','ПШ':'Пропущено шайб','ОБ':'Отражённые броски','%ОБ':'Процент отражённых бросков','КН':'Коэффициент надёжности = 60 мин × ПШ / ВП','И"0"':'Сухие игры','ВП':'Время на площадке',
'':'Номер игрока','Дата':'Дата матча','Команды':'Участники матча','Счёт':'Итоговый счёт','Турнир':'Турнир / этап сезона'
};
function statHint(label,fallbackTitle=''){const l=String(label||'').trim();return STAT_HINTS[l]||fallbackTitle||l}
function fallback(name){return 'data:image/svg+xml;charset=UTF-8,'+encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="140" height="140"><rect width="100%" height="100%" fill="#202a39"/><text x="50%" y="59%" text-anchor="middle" font-family="Arial" font-size="58" fill="#8795aa">${esc((name||'?').slice(0,1))}</text></svg>`)}
const KHL_API_BASE=location.pathname.startsWith('/khl-site')?'/khl-site':'';async function getJSON(url,opt){const target=(typeof url==='string'&&url.startsWith('/'))?KHL_API_BASE+url:url;const r=await fetch(target,opt);const p=await r.json();if(!r.ok)throw new Error(p.detail||p.error||r.statusText);return p}
function putCache(map,key,value,max=60){if(map.has(key))map.delete(key);map.set(key,value);while(map.size>max)map.delete(map.keys().next().value)}
function mergePlayerExtra(player,fields){if(!player||!fields)return player;const p=player._profile_full||(player._profile_full={...(player.profile||{})});for(const [k,v] of Object.entries(fields)){if(notEmpty(v)&&!notEmpty(p[k]))p[k]=v}return player}
function format(v){if(v===null||v===undefined||v==='')return '';if(typeof v==='number'&&!Number.isInteger(v))return Math.round(v*100)/100;return v}
function formatValue(v){if(v===null||v===undefined||v==='')return '';if(Array.isArray(v))return v.map(formatValue).join(', ');if(typeof v==='object')return Object.entries(v).map(([k,x])=>`${k}: ${formatValue(x)}`).join('; ');return format(v)}
function notEmpty(v){return v!==null&&v!==undefined&&v!==''&&!(Array.isArray(v)&&!v.length)&&!(typeof v==='object'&&v&&!Object.keys(v).length)}
function dateValue(value){if(!notEmpty(value))return '';if(typeof value==='number'||/^\d{9,13}$/.test(String(value))){let n=Number(value);if(n<1e12)n*=1000;const d=new Date(n);if(!Number.isNaN(d.valueOf()))return d.toLocaleDateString('ru-RU',{day:'numeric',month:'long',year:'numeric'})}return String(value)}
function physicalValue(value,unit){if(!notEmpty(value))return '';const text=String(value).trim();return new RegExp(`\\b${unit}\\b`,'i').test(text)?text:`${text} ${unit}`}
function gripValue(value){if(!notEmpty(value))return '';const text=String(value).trim(),v=text.toLocaleLowerCase('ru');if(['l','left','лев','левый'].includes(v))return 'левый';if(['r','right','прав','правый'].includes(v))return 'правый';return text}
function countryInfo(value){let raw=String(value||'').trim();if(!raw)return {name:'',code:'',emoji:''};const map={'россия':'RU','рф':'RU','беларусь':'BY','белоруссия':'BY','казахстан':'KZ','финляндия':'FI','швеция':'SE','чехия':'CZ','словакия':'SK','латвия':'LV','литва':'LT','эстония':'EE','сша':'US','соединенные штаты':'US','соединённые штаты':'US','канада':'CA','германия':'DE','швейцария':'CH','австрия':'AT','дания':'DK','норвегия':'NO','франция':'FR','словения':'SI','хорватия':'HR','польша':'PL','венгрия':'HU','китай':'CN','япония':'JP','южная корея':'KR','великобритания':'GB','италия':'IT','украина':'UA','грузия':'GE','армения':'AM','азербайджан':'AZ','узбекистан':'UZ'};let code='',name=raw;const m=raw.match(/^([A-Za-z]{2})\s+(.+)$/);if(m){code=m[1].toUpperCase();name=m[2].trim()}else code=map[raw.toLocaleLowerCase('ru')]||(/^[A-Za-z]{2}$/.test(raw)?raw.toUpperCase():'');const emoji=code?[...code].map(ch=>String.fromCodePoint(127397+ch.charCodeAt(0))).join(''):'';return {name,code,emoji}}
function countryFlagEmoji(value){return countryInfo(value).emoji}
function nameParts(value){const p=String(value||'').trim().split(/\s+/).filter(Boolean);return {last:p[0]||'',first:p[1]||'',middle:p.slice(2).join(' ')}}
function headerHtml(value){if(typeof value==='object'&&value)return `<abbr title="${esc(value.title||value.label||'')}">${esc(value.label||value.title||'')}</abbr>`;return esc(value)}
function table(headers,rows,opts={}){const body=(rows||[]).length?rows.map((row,ri)=>`<tr class="${opts.totalRows?.includes(ri)?'total-row':''}">${row.map((cell,ci)=>`<td class="${opts.numberCols?.includes(ci)?'number':''}">${cell}</td>`).join('')}</tr>`).join(''):`<tr><td colspan="${Math.max(1,headers.length)}">${esc(opts.empty||'Данных нет')}</td></tr>`;return `<div class="table-wrap">${opts.title?`<div class="table-title">${esc(opts.title)}</div>`:''}<table class="khl-table"><thead><tr>${headers.map(x=>`<th>${headerHtml(x)}</th>`).join('')}</tr></thead><tbody>${body}</tbody></table>${opts.note?`<div class="table-note">${esc(opts.note)}</div>`:''}</div>`}
function rowsTable(rows,title){const clean=(rows||[]).filter(x=>x&&notEmpty(x[1]));const body=clean.length?clean.map(([k,v])=>`<tr><td>${esc(k)}</td><td>${esc(formatValue(v))}</td></tr>`).join(''):`<tr><td colspan="2">Данных нет</td></tr>`;return `<div class="table-wrap">${title?`<div class="table-title">${esc(title)}</div>`:''}<table class="rows-table"><tbody>${body}</tbody></table></div>`}
function factsHtml(facts){return `<div class="fact-grid">${facts.filter(x=>notEmpty(x.value)).map(x=>`<div class="fact"><label>${esc(x.label)}</label><div>${x.html??esc(formatValue(x.value))}</div></div>`).join('')}</div>`}
function profileCard({type,name,nameEn,photo,role,team,facts,links,compact=false}){const roleBits=[team,role].filter(Boolean).map((x,i)=>i?`<span class="sep"></span><span>${esc(x)}</span>`:`<span>${esc(x)}</span>`).join('');return `<div class="profile-card${compact?' compact-player':''}"><div class="profile-head"><div class="profile-photo-wrap"><img class="profile-photo" src="${esc(photo||fallback(name))}" onerror="this.src='${fallback(name)}'"><div class="profile-type">${esc(type)}</div></div><div class="profile-content"><div class="profile-title-row"><h1 class="profile-title">${esc(name)}</h1>${nameEn?`<div class="profile-title-en">${esc(nameEn)}</div>`:''}</div>${roleBits?`<div class="profile-roleline">${roleBits}</div>`:''}${factsHtml(facts)}<div class="profile-linkline">${links.map(([t,u])=>`<a class="profile-link" target="_blank" href="${esc(u)}">${esc(t)} ↗</a>`).join('')}</div></div></div></div>`}
function scalarRows(obj,prefix='',depth=0){const rows=[];for(const [k,v] of Object.entries(obj||{})){const key=prefix?`${prefix}.${k}`:k;if(v===null||v===undefined||v==='')continue;if(Array.isArray(v)){if(v.length&&v.every(x=>typeof x!=='object'))rows.push([key,v.join(', ')]);continue}if(typeof v==='object'){if(depth<1)rows.push(...scalarRows(v,key,depth+1));continue}rows.push([key,k.includes('birthday')||k.includes('birth_date')?dateValue(v):v])}return rows}
function statMap(stats){const m={};for(const s of (stats||[]))m[String(s.id)]=s;return m}
function statColumns(player){
const ids=new Set(),titles={},descriptions={};
for(const record of Object.values(player.stages||{}))for(const st of ((record.data||{}).stats||[])){const id=String(st.id);ids.add(id);titles[id]=st.title||titles[id]||'';descriptions[id]=st.description||descriptions[id]||''}
for(const agg of Object.values(player.aggregates||{}))for(const st of (agg.stats||[])){const id=String(st.id);ids.add(id);titles[id]=st.title||titles[id]||'';descriptions[id]=st.description||descriptions[id]||''}
const order=player._is_goalie?GOALIE_ORDER:SKATER_ORDER,known=order.filter(x=>ids.has(x));
const rest=[...ids].filter(x=>!known.includes(x)).sort((a,b)=>(titles[a]||a).localeCompare(titles[b]||b,'ru'));
return [...known,...rest].map(id=>{const title=titles[id]||SHORT[id]||id,label=SHORT[id]||title;return {id,label,title,description:descriptions[id]||statHint(label,title)}})
}
function stageEntries(player){return Object.entries(player.stages||{}).sort((a,b)=>{const ai=Number(a[0]),bi=Number(b[0]);return (Number.isFinite(bi)?bi:-1)-(Number.isFinite(ai)?ai:-1)})}
function stageLabelParts(record,id){const full=record.stage_label||`Этап ${id}`;const parts=String(full).split(' · ').filter(Boolean);const first=parts[0]||'';const hasSeason=/^(?:19|20)\d{2}\/(?:19|20)\d{2}$/.test(first);return {season:hasSeason?first:'Год не указан',stage:(hasSeason?parts.slice(1):parts).filter(x=>!/^ID /.test(x)).join(' · ')||'Этап'}}
function stageStatsTable(player){const cols=statColumns(player),entries=stageEntries(player);const headers=[{label:'Турнир / Команда',title:'Сезон, этап и команда'},'',...cols.map(x=>({label:x.label,title:x.description||x.title}))];const rows=entries.map(([id,r])=>{const d=r.data||{},p=stageLabelParts(r,id),team=d.team?.name||((d.teams||[])[0]?.name)||'',m=statMap(d.stats||[]);const first=`<span class="season">${esc(p.season)}${p.stage&&p.stage!==p.season?` | ${esc(p.stage)}`:''}</span><span class="team">${esc(team)}</span>`;return [first,esc(d.shirt_number??player._profile_full?.shirt_number??''),...cols.map(c=>esc(format(m[c.id]?.val)))]});return table(headers,rows,{numberCols:[1,...cols.map((_,i)=>i+2)],note:'Показаны все статистические поля, найденные у выбранного игрока. Первая колонка закреплена.'})}
function aggregateTable(player){const cols=statColumns(player);const modes=[['regular','Регулярный чемпионат'],['playoff','Плей-офф'],['hope','Кубок Надежды'],['all','Всего в КХЛ']].filter(([k])=>(player.aggregates?.[k]?.stats||[]).length);const rows=modes.map(([key,title])=>{const m=statMap(player.aggregates[key].stats||[]);return [`<span class="season">${esc(title)}</span><span class="team">Этапов учтено: ${esc(player.aggregates[key].stage_count||0)}</span>`,...cols.map(c=>esc(format(m[c.id]?.val)))]});return table([{label:'Категория',title:'Категория суммарной статистики'},...cols.map(x=>({label:x.label,title:x.description||x.title}))],rows,{numberCols:cols.map((_,i)=>i+1),totalRows:rows.length?[rows.length-1]:[],note:'Используются суммарные строки KHL.ru; если сайт не отдал итоговую строку, применяется локальный резервный расчёт.'})}
function teamRows(teams){
const merged=new Map();
for(const t of (teams||[])){
const name=String(t.name||'').trim();if(!name||/^Див\./i.test(name)||/конференц/i.test(name))continue;
const key=name.toLocaleLowerCase('ru'),seasons=String(t.seasons||'').split(',').map(x=>x.trim()).filter(Boolean);
if(!merged.has(key))merged.set(key,{name,image:t.image||t.logo||'',url:t.url||'',seasons:new Set()});
const item=merged.get(key);if(!item.image&&(t.image||t.logo))item.image=t.image||t.logo;if(!item.url&&t.url)item.url=t.url;for(const season of seasons)item.seasons.add(season)
}
return [...merged.values()].sort((a,b)=>a.name.localeCompare(b.name,'ru')).map(t=>{
const seasons=[...t.seasons].sort((a,b)=>b.localeCompare(a));
const name=t.url?`<a href="${esc(t.url)}" target="_blank" rel="noopener noreferrer">${esc(t.name)}</a>`:esc(t.name);
const team=`<div class="team-history-cell"><img class="team-history-logo" src="${esc(t.image||fallback(t.name))}" onerror="this.src='${fallback(t.name)}'"><div class="team-history-name">${name}</div></div>`;
const seasonHtml=seasons.length?`<div class="season-tags">${seasons.map(x=>`<span class="season-tag">${esc(x)}</span>`).join('')}</div>`:'';
return [team,seasonHtml]
})
}
function playerHeaderHtml(player){
const i=player.identity||{},ie=player.identity_en||{},p=player._profile_full||player.profile||{},latest=(player.stages||{})[player._latest_stage_id]?.data||{},team=p.team||latest.team||{};
const generic=/(?:конференц|дивизион|^игроки?$|^players?$|^игрок$|^player$)/i;
const ruCandidates=[p.name,i.full_name].filter(x=>x&&!generic.test(String(x).trim()));
const enCandidates=[p.name_en,player._name_en,ie.full_name].filter(x=>x&&!generic.test(String(x).trim()));
const name=ruCandidates[0]||`Игрок #${esc(i.khl_id||p.khl_id||'')}`,nameEn=enCandidates[0]||'';let role=String(p.role||latest.role||'').trim();if(!['вратарь','защитник','нападающий'].includes(role.toLocaleLowerCase('ru')))role='';const countryRaw=p.country||p.country_name||p.nationality||p.citizenship||latest.country||latest.nationality||latest.citizenship||'',country=countryInfo(countryRaw),birthday=p.birthday||p.birth_date||p.date_of_birth||p.birthdate||p.dob,height=p.height||p.height_cm||p.player_height,weight=p.weight||p.weight_kg||p.player_weight,stick=p.stick||p.shoots||p.grip||p.handedness||p.shooting_side;
const facts=[{label:'Дата рождения',value:dateValue(birthday)},{label:'Возраст',value:p.age||latest.age},{label:'Страна / гражданство',value:country.name,html:country.name?`<span class="flag-emoji">${country.emoji}</span>${esc(country.name)}`:undefined},{label:'Рост',value:physicalValue(height,'см')},{label:'Вес',value:physicalValue(weight,'кг')},{label:'Хват',value:gripValue(stick)},{label:'Сборная',value:p.national_team||latest.national_team},{label:'Контракт до',value:p.contract_until||latest.contract_until},{label:'Место рождения',value:p.birth_place||latest.birth_place},{label:'Номер',value:p.shirt_number||latest.shirt_number},{label:'KHL ID',value:i.khl_id||p.khl_id},{label:'Сезонов КХЛ',value:p.seasons_count?.khl||latest.seasons_count?.khl}],links=[];
if(i.khl_id||p.khl_id)links.push(['Профиль на KHL.ru',`https://www.khl.ru/players/${i.khl_id||p.khl_id}/`]);
return profileCard({type:role||'Амплуа не указано',name,nameEn,photo:p.image||p.photo,role:'',team:team.name||'',facts,links,compact:true})
}
function playerOverviewHtml(player){
let html=`<div class="section"><h2>${player._is_goalie?'Суммарная вратарская статистика':'Суммарная статистика КХЛ'}</h2></div>${aggregateTable(player)}`;
html+=`<div class="section"><h2>${player._is_goalie?'Вратарская статистика по сезонам':'Статистика по сезонам и этапам'}</h2></div>${stageStatsTable(player)}`;
const p=player._profile_full||player.profile||{},latest=(player.stages||{})[player._latest_stage_id]?.data||{},allTeams=p.teams||latest.teams||[],allTeamRows=teamRows(allTeams);if(allTeamRows.length)html+=table(['Команда','Сезоны'],allTeamRows,{title:'Все команды'});
return html
}
function playerTabsHtml(){return `<div class="player-tabs"><button id="playerTabOverview" class="player-tab ${S.playerTab==='overview'?'active':''}" onclick="showPlayerTab('overview')">Статистика</button><button id="playerTabMatches" class="player-tab ${S.playerTab==='matches'?'active':''}" onclick="showPlayerTab('matches')">Матчи</button></div><div id="playerTabContent"></div>`}
function renderPlayer(player,{keepTab=false}={}){S.currentPlayer=player;S.currentPlayerId=String((player.identity||{}).id||(player.profile||{}).id||selectedId('players')||'');if(!keepTab){S.playerTab='overview';S.matchesPage=1}$('detail').className='';$('detail').innerHTML=playerHeaderHtml(player)+playerTabsHtml();if(S.playerTab==='matches')loadPlayerMatches(S.matchesPage||1);else $('playerTabContent').innerHTML=playerOverviewHtml(player)}
function showPlayerTab(tab){if(!S.currentPlayer||!['overview','matches'].includes(tab))return;S.playerTab=tab;const a=$('playerTabOverview'),b=$('playerTabMatches');if(a)a.classList.toggle('active',tab==='overview');if(b)b.classList.toggle('active',tab==='matches');if(tab==='overview'){$('playerTabContent').innerHTML=playerOverviewHtml(S.currentPlayer);return}S.matchesPage=1;loadPlayerMatches(1)}
function renderPlayerMatches(data){
const host=$('playerTabContent');if(!host)return;
const rawHeaders=data.headers||[],headers=rawHeaders.map(h=>({label:String(h||''),title:statHint(String(h||''),String(h||''))}));
const teamIndex=rawHeaders.findIndex(x=>String(x).toLocaleLowerCase('ru').includes('команд'));
const rows=(data.rows||[]).map(row=>(row.values||[]).map((value,ci)=>{const cell=esc(formatValue(value));return row.url&&ci===teamIndex?`<a class="match-link" href="${esc(row.url)}" target="_blank" rel="noopener noreferrer">${cell} ↗</a>`:cell}));
const from=data.range_start||((data.page-1)*(data.page_size||30)+1),to=data.range_end||(rows.length?from+rows.length-1:0);
let html=table(headers,rows,{title:`Матчи КХЛ · ${data.total||rows.length}`,empty:data.total?`KHL сообщает ${data.total} матчей, но строки этой страницы не удалось разобрать`:'Матчей нет',note:data.total&&rows.length?`Показаны игры ${from}${to} из ${data.total}`:''});
html+=`<div class="matches-pager"><button ${data.page<=1?'disabled':''} onclick="loadPlayerMatches(${Math.max(1,data.page-1)})">← Назад</button><span class="small">Страница ${data.page} из ${data.pages||1}</span><button ${data.page>=(data.pages||1)?'disabled':''} onclick="loadPlayerMatches(${data.page+1})">Вперёд →</button></div>`;host.innerHTML=html
}
async function loadPlayerMatches(page=1){S.matchesPage=Math.max(1,Number(page)||1);page=S.matchesPage;const id=S.currentPlayerId;if(!id||S.playerTab!=='matches')return;const host=$('playerTabContent');if(!host)return;const key=`${id}:${page}`;if(S.matchesCache.has(key)){renderPlayerMatches(S.matchesCache.get(key));return}host.innerHTML='<div class="matches-state">Загрузка матчей KHL…</div>';try{const data=await getJSON(`/api/player-matches/${encodeURIComponent(id)}?page=${page}`);if(S.playerTab!=='matches'||S.currentPlayerId!==String(id))return;putCache(S.matchesCache,key,data,80);renderPlayerMatches(data)}catch(e){console.error(e);if(S.playerTab==='matches'&&S.currentPlayerId===String(id))host.innerHTML=`<div class="matches-state">Не удалось загрузить матчи: ${esc(e.message||e)}</div>`}}
async function loadPlayerExtra(id,player,seq){const key=String(id);if(S.extraCache.has(key)){mergePlayerExtra(player,S.extraCache.get(key));if(seq===S.openSeq&&S.kind==='players'&&selectedId('players')===key)renderPlayer(player,{keepTab:true});return}try{const data=await getJSON(`/api/player-extra/${encodeURIComponent(id)}`);putCache(S.extraCache,key,data.fields||{},100);mergePlayerExtra(player,data.fields||{});if(seq===S.openSeq&&S.kind==='players'&&selectedId('players')===key)renderPlayer(player,{keepTab:true})}catch(e){console.debug('Дополнительный профиль KHL недоступен',e)}}
function parsedTable(t,prefix=''){const n=Math.max(t.headers?.length||0,...(t.rows||[]).map(r=>r.values?.length||0));const heads=(t.headers?.length?t.headers:Array.from({length:n},(_,i)=>`Колонка ${i+1}`));const rows=(t.rows||[]).map(r=>Array.from({length:n},(_,i)=>esc(formatValue((r.values||[])[i]))));return table(heads,rows,{title:`${prefix}${t.title||''}`.trim()})}
function linksTable(items,title){const rows=(items||[]).filter(x=>x&&(x.title||x.url)).map(x=>[esc(x.title||x.url||''),x.url?`<a href="${esc(x.url)}" target="_blank" rel="noopener noreferrer">Открыть ↗</a>`:'']);return rows.length?table(['Название','Ссылка'],rows,{title}):''}
function sectionsTable(sections,title){const rows=(sections||[]).map(s=>[esc(s.title||''),esc(s.text||s.content||''),esc((s.links||[]).map(x=>x.title||x.url).join(', ')||'')]);return rows.length?table(['Раздел','Текст','Ссылки'],rows,{title}):''}
function renderStaff(person){const profile=person.profile||{},ru=person.languages?.ru||{},en=person.languages?.en||{},name=person._display_name||person.name_ru||profile.name||person.name||'Без имени',nameEn=person._display_name_en||person.name_en||profile.name_en||en.name||'',fields=profile.fields||ru.fields||{},apps=person.appearances||[],photos=profile.photos||person.photos||[],type=S.kind==='coaches'?'Тренер':'Судья',role=fields['Амплуа']||fields['Роль']||fields['Role']||apps.find(x=>x.context?.role)?.context?.role||'',teams=[...new Set(apps.flatMap(x=>(x.team_links||[]).map(t=>t.title)).filter(Boolean))],facts=Object.entries(fields).slice(0,10).map(([label,value])=>({label,value:label.toLowerCase().includes('рожд')?dateValue(value):value})),links=[];if(person.url_ru||person.url)links.push(['Профиль на KHL.ru',person.url_ru||person.url]);if(person.url_en)links.push(['English profile',person.url_en]);let html=profileCard({type,name,nameEn,photo:(photos.find(x=>x&&!/logo|sprite|icon|banner|advert|sponsor/i.test(String(x)))||''),role,team:teams[0]||'',facts,links});html+=table(['Сезон','Этап','Роль / амплуа','Команды','Язык','Описание'],apps.map(a=>[esc(a.context?.season||''),esc(a.context?.stage||a.context?.stage_type||''),esc(a.context?.role||''),esc((a.team_links||[]).map(x=>x.title).join(', ')||''),esc(a.language||a.context?.language||''),esc(a.text||'')]),{title:'Все появления в списках'});for(const t of (profile.tables||ru.tables||[]))html+=parsedTable(t,'RU · ');for(const t of (profile.tables_en||en.tables||[]))html+=parsedTable(t,'EN · ');html+=linksTable(profile.teams||ru.teams||[],'Команды');html+=linksTable(profile.events||ru.events||[],'Матчи и события');html+=linksTable(profile.news||ru.news||[],'Новости');$('detail').className='';$('detail').innerHTML=html}
async function loadStatus(){S.status=await getJSON('/api/status');const c=S.status.counts,b=Number(S.status.broken_player_names||0);$('statusLine').textContent=`Игроки: ${c.players} · тренеры: ${c.coaches} · судьи: ${c.officials}`+(b?` · ⚠ обновите игроков: имена требуют пересборки (${b})`:'');fillFilters()}
async function setKind(kind){
if(!KINDS.includes(kind)||kind===S.kind)return;
captureListState();
S.kind=kind;
S.page=Math.max(1,Number(S.pageByKind[kind])||1);
saveUiState();
document.querySelectorAll('.tabs button').forEach(x=>x.classList.toggle('active',x.id===`tab-${kind}`));
$('detail').className='empty';
$('detail').textContent='Загрузка карточки…';
fillFilters();
await loadList(false,{restoreScroll:true});
if(kind!==S.kind)return;
const id=selectedId(kind);
if(id)await openItem(id,{updateSelection:false});
else{$('detail').className='empty';$('detail').textContent='Выбери запись слева'}
}
function fillFilters(){if(!S.status)return;const f=S.status.filters[S.kind]||{};$('season').innerHTML='<option value="">Все сезоны</option>'+(f.seasons||[]).map(x=>`<option>${esc(x)}</option>`).join('');let values=[],label='Все';if(S.kind==='players'){values=f.roles||[];label='Все амплуа'}else if(S.kind==='coaches'){values=f.teams||[];label='Все команды'}else{values=f.roles||[];label='Все амплуа'}$('extra').innerHTML=`<option value="">${label}</option>`+values.map(x=>`<option>${esc(x)}</option>`).join('')}
let debounce=null;function debouncedLoad(){clearTimeout(debounce);debounce=setTimeout(()=>loadList(true),180)}
async function loadList(reset=false,{restoreScroll=true}={}){
const kind=S.kind;
if(reset){S.page=1;S.pageByKind[kind]=1;S.scrollByKind[kind]=0}else S.page=Math.max(1,Number(S.pageByKind[kind])||S.page||1);
$('list').innerHTML='<div class="small" style="padding:15px">Загрузка…</div>';
const q=new URLSearchParams({page:S.page,limit:100,q:$('search').value,season:$('season').value,extra:$('extra').value});
const p=await getJSON(`/api/list/${kind}?${q}`);
if(kind!==S.kind)return;
S.page=p.page;S.pages=p.pages;S.pageByKind[kind]=p.page;
$('total').textContent=`${p.total} записей`;$('pageInfo').textContent=`${p.page} / ${p.pages}`;$('prev').disabled=p.page<=1;$('next').disabled=p.page>=p.pages;
const active=selectedId(kind);
$('list').innerHTML=p.items.map(item=>`<div class="row ${active===String(item.id)?'active':''}" data-id="${esc(item.id)}" onclick="openItem('${esc(item.id)}')"><img class="avatar" src="${esc(item.image||fallback(item.name))}" onerror="this.src='${fallback(item.name)}'"><div><div class="name">${esc(item.name)}</div>${item.name_en?`<div class="name-en">${esc(item.name_en)}</div>`:''}<div class="badges"><span class="badge">ID ${esc(item.id)}</span>${item.subtitle?`<span class="badge">${esc(item.subtitle)}</span>`:''}${item.count?`<span class="badge good">${esc(item.count)}</span>`:''}</div></div><span class="small">${esc(item.role||'')}</span></div>`).join('')||'<div class="small" style="padding:15px">Ничего не найдено</div>';
saveUiState();
if(restoreScroll&&!reset)restoreListScroll(kind);else{S.scrollByKind[kind]=0;$('list').scrollTop=0;markActiveRow(active)}
}
function changePage(d){const n=S.page+d;if(n>=1&&n<=S.pages){captureListState();S.page=n;S.pageByKind[S.kind]=n;S.scrollByKind[S.kind]=0;saveUiState();loadList(false,{restoreScroll:false})}}
async function openItem(id,{updateSelection=true}={}){
const kind=S.kind,key=`${kind}:${id}`,seq=++S.openSeq;
if(updateSelection){S.selectedByKind[kind]=String(id);S.pageByKind[kind]=S.page;S.scrollByKind[kind]=$('list').scrollTop||0;saveUiState()}
markActiveRow(String(id));
try{
let item=S.itemCache.get(key);
if(!item){item=await getJSON(`/api/item/${kind}/${encodeURIComponent(id)}`);putCache(S.itemCache,key,item,80)}
if(seq!==S.openSeq||kind!==S.kind)return;
if(kind==='players'){
renderPlayer(item);
}else renderStaff(item);
markActiveRow(selectedId(kind));
}catch(e){console.error(e);if(seq!==S.openSeq)return;$('detail').className='empty';$('detail').textContent=`Не удалось открыть карточку: ${e.message||e}`}
}
async function collectData(kind){try{await getJSON(`/api/collect/${kind}`,{method:'POST'});pollJob()}catch(e){alert(e.message)}}
async function reloadData(){captureListState();await getJSON('/api/reload',{method:'POST'});await loadStatus();await loadList(false,{restoreScroll:true});const id=selectedId();if(id)await openItem(id,{updateSelection:false})}
async function pollJob(){clearInterval(S.timer);$('job').classList.add('show');S.timer=setInterval(async()=>{const j=await getJSON('/api/job');$('jobTitle').textContent=j.title||'Обновление';$('jobMessage').textContent=j.message||'';$('jobProgress').style.display=j.running?'block':'none';if(!j.running){clearInterval(S.timer);setTimeout(()=>$('job').classList.remove('show'),4000);captureListState();await loadStatus();await loadList(false,{restoreScroll:true});const id=selectedId();if(id)await openItem(id,{updateSelection:false})}},800)}
(async()=>{
document.querySelectorAll('.tabs button').forEach(x=>x.classList.toggle('active',x.id===`tab-${S.kind}`));
await loadStatus();
S.page=Math.max(1,Number(S.pageByKind[S.kind])||1);
await loadList(false,{restoreScroll:true});
const id=selectedId();
if(id)await openItem(id,{updateSelection:false});
const j=await getJSON('/api/job');if(j.running)pollJob()
})()
</script>
</body>
</html>
'''
SUM_IDS = {"gp", "g", "a", "pts", "pim", "pm", "fow", "sds", "w", "l", "sop", "ga", "sv", "so", "toi", "time_on_ice", "distance_travelled"}
WEIGHTED_IDS = {"toi_avg", "sft_avg", "pim_avg"}
MAX_IDS = {"top_speed"}
STAT_ORDER = ["gp", "g", "a", "pts", "pm", "pim", "fow", "sds", "w", "l", "ga", "sv", "sv_pct", "gaa", "so", "sop", "toi", "toi_avg", "sft_avg", "pim_avg", "top_speed", "time_on_ice", "distance_travelled"]
def read_json(path: Path, default: Any) -> Any:
if not path.exists():
return default
try:
with path.open("r", encoding="utf-8") as fh:
return json.load(fh)
except (OSError, json.JSONDecodeError):
return default
KNOWN_STAGE_SEASONS: dict[int, str] = {
27: "2008/2009",
31: "2008/2009",
35: "2009/2010",
39: "2009/2010",
43: "2010/2011",
47: "2010/2011",
51: "2011/2012",
55: "2011/2012",
59: "2012/2013",
63: "2012/2013",
67: "2012/2013",
3: "2013/2014",
7: "2013/2014",
11: "2013/2014",
15: "2014/2015",
19: "2014/2015",
23: "2015/2016",
71: "2015/2016",
75: "2016/2017",
83: "2016/2017",
89: "2017/2018",
97: "2017/2018",
105: "2018/2019",
141: "2018/2019",
157: "2019/2020",
177: "2019/2020",
189: "2020/2021",
197: "2020/2021",
209: "2021/2022",
225: "2021/2022",
235: "2022/2023",
259: "2022/2023",
275: "2023/2024",
299: "2023/2024",
323: "2024/2025",
359: "2024/2025",
370: "2025/2026",
395: "2025/2026",
407: "2026/2027",
}
def stage_season(stage: dict[str, Any]) -> str:
explicit = str(stage.get("season") or "").strip()
if explicit:
return explicit
source = stage.get("source_metadata")
if isinstance(source, dict):
for key in ("season", "season_name", "season_title", "season_year", "season_years", "years", "year"):
value = source.get(key)
if value not in (None, ""):
match = re.search(r"(?:19|20)\d{2}\s*[/–—-]\s*(?:19|20)?\d{2}", str(value))
if match:
return match.group(0).replace("", "/").replace("", "/").replace("-", "/").replace(" ", "")
try:
stage_id = int(stage.get("id"))
except (TypeError, ValueError):
return ""
return KNOWN_STAGE_SEASONS.get(stage_id, "")
def stage_label(stage: dict[str, Any]) -> str:
season = stage_season(stage)
title = stage.get("title") or "Этап"
sid = stage.get("id")
return " · ".join(x for x in (season, title, f"ID {sid}") if x)
GENERIC_STAFF_NAMES = {
"тренер", "тренеры", "тренеры кхл", "coach", "coaches", "khl coaches",
"судья", "судьи", "судьи кхл", "official", "officials", "referee", "referees",
}
def normalize_person_text(value: Any) -> str:
return re.sub(r"\s+", " ", str(value or "")).strip(" \t\r\n,;:|—–-")
def is_generic_staff_name(value: Any) -> bool:
text = normalize_person_text(value).casefold()
if not text:
return True
if text in GENERIC_STAFF_NAMES:
return True
return text.startswith("тренеры кхл ") or text.startswith("судьи кхл ")
def is_valid_player_name(value: Any) -> bool:
text = normalize_person_text(value)
if not text:
return False
low = text.casefold()
blocked = (
"конференц", "дивизион", "игроки", "тренеры", "судьи", "статистика",
"матчи", "амплуа", "гражданство", "дата рождения", "континентальная хоккейная лига",
)
if any(x in low for x in blocked):
return False
return len(text.split()) >= 2 and bool(re.search(r"[А-Яа-яЁё]", text))
def player_sidebar_name(item: dict[str, Any], item_id: str) -> str:
"""Stable left-menu name in surname + first-name order.
New collections store profile.list_name_ru directly from KHL's player list.
Older files are repaired from structured identity fields when possible; bad
global-navigation values such as «Конференция Запад» are never displayed.
"""
identity = item.get("identity") or {}
profile = item.get("profile") or {}
direct = normalize_person_text(profile.get("list_name_ru"))
if is_valid_player_name(direct):
return direct
first = normalize_person_text(identity.get("first_name"))
last = normalize_person_text(identity.get("last_name"))
structured = f"{last} {first}".strip()
if is_valid_player_name(structured):
return structured
for candidate in (identity.get("full_name"), profile.get("name")):
text = normalize_person_text(candidate)
if is_valid_player_name(text):
return text
return f"Игрок #{item_id}"
def compose_first_last(first: Any, last: Any, middle: Any = None, *, include_middle: bool = False) -> str:
parts = [normalize_person_text(first)]
if include_middle:
parts.append(normalize_person_text(middle))
parts.append(normalize_person_text(last))
return " ".join(part for part in parts if part)
def reorder_khl_person_name(value: Any) -> str:
"""KHL обычно отдаёт персонал как Фамилия Имя [Отчество]."""
text = normalize_person_text(value)
if is_generic_staff_name(text):
return ""
parts = text.split()
if len(parts) < 2:
return text
# Для бокового списка показываем именно Имя + Фамилия.
return f"{parts[1]} {parts[0]}"
def staff_display_name(item: dict[str, Any], language: str = "ru") -> str:
profile = item.get("profile") or {}
languages = item.get("languages") or {}
lang_profile = languages.get(language) or {}
suffix = "_ru" if language == "ru" else "_en"
explicit = compose_first_last(
item.get(f"first_name{suffix}"),
item.get(f"last_name{suffix}"),
)
if explicit and not is_generic_staff_name(explicit):
return explicit
for source in (lang_profile, profile):
parts = source.get("name_parts") or {}
explicit = compose_first_last(parts.get("first_name"), parts.get("last_name"))
if explicit and not is_generic_staff_name(explicit):
return explicit
candidates = [
item.get(f"name{suffix}"),
lang_profile.get("name"),
profile.get("name_en" if language == "en" else "name"),
]
page_title = normalize_person_text(lang_profile.get("page_title") or profile.get("page_title"))
if page_title:
page_name = re.split(r",|\|| — | - ", page_title, maxsplit=1)[0]
candidates.append(page_name)
for appearance in item.get("appearances") or []:
if language and appearance.get("language") not in (None, "", language):
continue
candidates.extend((appearance.get("name"), appearance.get("title")))
if language == "ru":
candidates.append(item.get("name"))
for candidate in candidates:
result = reorder_khl_person_name(candidate)
if result:
return result
if language == "en":
return ""
return f"Персона #{item.get('id', '')}"
def valid_person_image(value: Any) -> str:
url = str(value or "").strip()
if not url:
return ""
low = url.casefold()
bad = ("logo", "logotype", "sprite", "icon", "favicon", "banner", "advert", "sponsor", "/flags/", "flag_")
return "" if any(token in low for token in bad) else url
def aggregate_player(player: dict[str, Any], stages_by_id: dict[str, dict[str, Any]], mode: str) -> dict[str, Any]:
acc: dict[str, dict[str, Any]] = {}
stage_count = 0
for stage_id, record in (player.get("stages") or {}).items():
title = str(stages_by_id.get(str(stage_id), {}).get("title") or "").casefold()
if mode == "regular" and "регуляр" not in title:
continue
if mode == "playoff" and "плей" not in title:
continue
if mode == "hope" and "надежд" not in title:
continue
data = record.get("data") or {}
stats = data.get("stats") or []
if not stats:
continue
stage_count += 1
gp = next((float(s.get("val") or 0) for s in stats if s.get("id") == "gp"), 0.0)
for stat in stats:
sid = str(stat.get("id") or "")
try:
value = float(stat.get("val"))
except (TypeError, ValueError):
continue
if not sid:
continue
item = acc.setdefault(sid, {"id": sid, "title": stat.get("title") or sid, "sum": 0.0, "weighted": 0.0, "weight": 0.0, "max": None})
if sid in WEIGHTED_IDS:
if gp > 0:
item["weighted"] += value * gp
item["weight"] += gp
elif sid in MAX_IDS:
item["max"] = value if item["max"] is None else max(item["max"], value)
elif sid in SUM_IDS:
item["sum"] += value
stats_out = []
for sid, item in acc.items():
if sid in WEIGHTED_IDS:
if item["weight"] <= 0:
continue
value = item["weighted"] / item["weight"]
elif sid in MAX_IDS:
value = item["max"]
elif sid in SUM_IDS:
value = item["sum"]
else:
continue
if isinstance(value, float) and value.is_integer():
value = int(value)
stats_out.append({"id": sid, "title": item["title"], "val": value})
by_id = {x["id"]: x["val"] for x in stats_out}
saves = float(by_id.get("sv") or 0)
goals = float(by_id.get("ga") or 0)
toi = float(by_id.get("toi") or 0)
if saves + goals > 0:
stats_out.append({"id": "sv_pct", "title": "Процент отражённых бросков", "val": saves * 100 / (saves + goals)})
if toi > 0:
stats_out.append({"id": "gaa", "title": "Коэффициент надёжности", "val": goals * 60 / toi})
order = {key: idx for idx, key in enumerate(STAT_ORDER)}
stats_out.sort(key=lambda x: order.get(x["id"], 999))
return {"stage_count": stage_count, "stats": stats_out}
def _numeric_stage_sort(value: str) -> tuple[int, str]:
text = str(value)
return (int(text) if text.isdigit() else -1, text)
COUNTRY_BY_FLAG_CODE = {
"RU": "Россия", "BY": "Беларусь", "KZ": "Казахстан", "FI": "Финляндия",
"SE": "Швеция", "CZ": "Чехия", "SK": "Словакия", "LV": "Латвия",
"LT": "Литва", "EE": "Эстония", "US": "США", "CA": "Канада",
"DE": "Германия", "CH": "Швейцария", "AT": "Австрия", "DK": "Дания",
"NO": "Норвегия", "FR": "Франция", "SI": "Словения", "HR": "Хорватия",
"PL": "Польша", "HU": "Венгрия", "CN": "Китай", "JP": "Япония",
"KR": "Южная Корея", "GB": "Великобритания", "IT": "Италия", "UA": "Украина",
"GE": "Грузия", "AM": "Армения", "AZ": "Азербайджан", "UZ": "Узбекистан",
}
def _profile_value(profile: dict[str, Any], *keys: str) -> Any:
for key in keys:
value = profile.get(key)
if value not in (None, "", [], {}):
return value
return None
def normalize_player_profile_fields(profile: dict[str, Any]) -> dict[str, Any]:
"""Приводит разные имена полей API/KHL.ru к одному виду для интерфейса."""
aliases = {
"birthday": ("birthday", "birth_date", "date_of_birth", "birthdate", "dob"),
"country": ("country", "country_name", "nationality", "citizenship", "citizenship_name"),
"height": ("height", "height_cm", "player_height"),
"weight": ("weight", "weight_kg", "player_weight"),
"stick": ("stick", "shoots", "grip", "handedness", "shooting_side", "shooting_hand"),
}
for target, keys in aliases.items():
if profile.get(target) in (None, "", [], {}):
value = _profile_value(profile, *keys)
if value not in (None, "", [], {}):
profile[target] = value
if profile.get("country") in (None, "", [], {}):
flag = str(profile.get("flag_image_url") or profile.get("flag") or "")
match = re.search(r"/([A-Za-z]{2})\.png(?:\?|$)", flag)
if match:
country = COUNTRY_BY_FLAG_CODE.get(match.group(1).upper())
if country:
profile["country"] = country
return profile
def _load_player_site_cache() -> dict[str, Any]:
global _PLAYER_SITE_CACHE
with PLAYER_SITE_CACHE_LOCK:
if _PLAYER_SITE_CACHE is None:
raw = read_json(PLAYER_SITE_CACHE_FILE, {"profiles": {}})
if not isinstance(raw, dict):
raw = {"profiles": {}}
raw.setdefault("profiles", {})
_PLAYER_SITE_CACHE = raw
return _PLAYER_SITE_CACHE
def _save_player_site_cache() -> None:
with PLAYER_SITE_CACHE_LOCK:
cache = _load_player_site_cache()
PLAYER_SITE_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = PLAYER_SITE_CACHE_FILE.with_suffix(".json.tmp")
tmp.write_text(json.dumps(cache, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
tmp.replace(PLAYER_SITE_CACHE_FILE)
def parse_khl_player_profile_html(html: str) -> dict[str, Any]:
"""Извлекает основные антропометрические поля из персональной страницы KHL.ru."""
soup = BeautifulSoup(html or "", "html.parser")
values = [re.sub(r"\s+", " ", x).strip() for x in soup.stripped_strings]
labels = {
"дата рождения": "birthday",
"гражданство": "country",
"рост": "height",
"вес": "weight",
"хват": "stick",
"возраст": "age",
}
label_names = set(labels)
result: dict[str, Any] = {}
for idx, text in enumerate(values):
normalized = text.casefold().strip(" :")
target = labels.get(normalized)
if not target or target in result:
continue
for candidate in values[idx + 1:idx + 6]:
cand = candidate.strip()
cand_norm = cand.casefold().strip(" :")
if not cand or cand_norm in label_names:
continue
if len(cand) > 100:
break
if target in {"height", "weight", "age"}:
m = re.search(r"\b(\d{1,3})\b", cand)
if not m:
continue
result[target] = int(m.group(1))
else:
result[target] = cand
break
stick = str(result.get("stick") or "").casefold().strip()
if stick in {"l", "left", "лев", "левый"}:
result["stick"] = "левый"
elif stick in {"r", "right", "прав", "правый"}:
result["stick"] = "правый"
return result
def _download_khl_player_page(khl_id: int, matches_page: int = 1) -> str:
url = f"https://www.khl.ru/players/{khl_id}/"
params = None
if matches_page > 1:
# KHL использует стандартную битриксовую пагинацию таблицы матчей.
params = {"PAGEN_1": matches_page, "idplayer": khl_id}
headers = {
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.7",
"Cache-Control": "no-cache",
}
if curl_requests is not None:
response = curl_requests.get(
url, params=params, headers=headers, impersonate="chrome",
timeout=8, allow_redirects=True,
)
response.raise_for_status()
return response.text
response = std_requests.get(
url, params=params,
headers={**headers, "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/150 Safari/537.36"},
timeout=8, allow_redirects=True,
)
response.raise_for_status()
return response.text
def _download_khl_player_profile(khl_id: int) -> str:
return _download_khl_player_page(khl_id, 1)
def get_khl_site_player_profile(khl_id: int) -> dict[str, Any]:
key = str(khl_id)
now = time.time()
with PLAYER_SITE_CACHE_LOCK:
cache = _load_player_site_cache()
entry = (cache.get("profiles") or {}).get(key) or {}
fetched_at = float(entry.get("fetched_at") or 0)
fields = entry.get("fields") if isinstance(entry.get("fields"), dict) else {}
ttl = 30 * 86400 if fields else 15 * 60
if fetched_at and now - fetched_at < ttl:
return dict(fields)
failed_at = _PLAYER_SITE_FAILURES.get(key, 0)
if failed_at and now - failed_at < 120:
return dict(fields)
try:
html = _download_khl_player_profile(khl_id)
fields = parse_khl_player_profile_html(html)
except Exception as exc:
_PLAYER_SITE_FAILURES[key] = now
print(f"KHL profile {khl_id}: {exc}", file=sys.stderr)
return dict(fields)
with PLAYER_SITE_CACHE_LOCK:
cache = _load_player_site_cache()
cache.setdefault("profiles", {})[key] = {
"fetched_at": now,
"fields": fields,
}
_PLAYER_SITE_FAILURES.pop(key, None)
try:
_save_player_site_cache()
except OSError as exc:
print(f"Не удалось записать кэш профиля KHL {khl_id}: {exc}", file=sys.stderr)
return dict(fields)
def _clean_match_cell(value: str) -> str:
return re.sub(r"\s+", " ", value or "").strip()
def _looks_like_match_date(value: str) -> bool:
text = _clean_match_cell(value)
return bool(
re.fullmatch(r"\d{4}-\d{2}-\d{2}", text)
or re.fullmatch(r"\d{1,2}\s+[А-Яа-яЁё]{3,}\s+\d{4}", text)
or re.fullmatch(r"\d{1,2}\.\d{1,2}\.\d{4}", text)
)
def _looks_like_score(value: str) -> bool:
return bool(re.search(r"\b\d{1,2}\s*:\s*\d{1,2}\b", _clean_match_cell(value)))
def _match_stat_headers(table_headers: list[str], stat_count: int) -> list[str]:
heads = [_clean_match_cell(x) for x in table_headers]
# Основная таблица KHL начинается с «Турнир / Команда», а матч — с Дата/Команды/Счёт.
if heads and ("турнир" in heads[0].casefold() or "команд" in heads[0].casefold()):
heads = heads[1:]
# В строке одного матча нет колонки И=1; сайт её не дублирует.
if len(heads) == stat_count + 1:
gp_idx = next((i for i, h in enumerate(heads) if h.casefold().strip() in {"и", "игры"}), -1)
if gp_idx >= 0:
heads.pop(gp_idx)
if len(heads) > stat_count:
# Сохраняем номер и последние статистические поля; лишние служебные заголовки отбрасываем.
if heads and heads[0] in {"", "#", "N"}:
heads = [heads[0]] + heads[-(stat_count - 1):] if stat_count > 1 else [heads[0]]
else:
heads = heads[-stat_count:]
while len(heads) < stat_count:
heads.append(f"Показатель {len(heads) + 1}")
return heads[:stat_count]
def parse_khl_player_matches_html(html: str, page: int = 1) -> dict[str, Any]:
"""Разбирает строки матчей с карточки игрока KHL.ru.
На актуальном KHL матчи могут находиться в той же широкой таблице, что и сезонная
статистика, поэтому наличие отдельного thead «Дата / Команды / Счёт» не требуется.
Ищем строки по фактическому формату: дата → пара команд → счёт → статистика.
"""
soup = BeautifulSoup(html or "", "html.parser")
page_text = _clean_match_cell(soup.get_text(" ", strip=True))
total = 0
start = 0
end = 0
match = re.search(r"Игры\s+с\s+(\d+)\s+по\s+(\d+)\s+из\s+(\d+)", page_text, flags=re.I)
if match:
start, end, total = map(int, match.groups())
best_rows: list[dict[str, Any]] = []
best_headers: list[str] = []
for table_node in soup.find_all("table"):
table_headers: list[str] = []
for trh in table_node.select("thead tr"):
cells = trh.find_all(["th", "td"], recursive=False)
candidate = [_clean_match_cell(c.get_text(" ", strip=True)) for c in cells]
if len(candidate) >= len(table_headers):
table_headers = candidate
if not table_headers:
first = table_node.find("tr")
if first:
cells = first.find_all(["th", "td"], recursive=False)
if cells and any(c.name == "th" for c in cells):
table_headers = [_clean_match_cell(c.get_text(" ", strip=True)) for c in cells]
rows: list[dict[str, Any]] = []
section = ""
for tr in table_node.find_all("tr"):
cells = tr.find_all(["th", "td"], recursive=False)
if not cells:
continue
values = [_clean_match_cell(c.get_text(" ", strip=True)) for c in cells]
if not any(values):
continue
if len(cells) == 1 or (len(cells) <= 2 and cells[0].get("colspan")):
candidate = values[0]
if candidate and "игры с " not in candidate.casefold():
section = candidate
continue
# В некоторых версиях KHL первые три значения могут быть объединены иначе.
date_idx = next((i for i, value in enumerate(values[:3]) if _looks_like_match_date(value)), -1)
if date_idx < 0 or len(values) < date_idx + 3:
continue
date_value = values[date_idx]
teams_value = values[date_idx + 1]
score_value = values[date_idx + 2]
if not _looks_like_score(score_value):
continue
if not re.search(r"[-–—‐‑‒−]", teams_value) and len(teams_value.split()) < 2:
continue
stats = values[date_idx + 3:]
row_url = ""
for link in tr.find_all("a", href=True):
href = str(link.get("href") or "")
href_l = href.casefold()
if any(token in href_l for token in ("/game/", "/calendar/", "/protocol/", "/text/", "match")):
row_url = href if href.startswith("http") else f"https://www.khl.ru{href}"
break
rows.append({"section": section, "prefix": [date_value, teams_value, score_value], "stats": stats, "url": row_url})
if len(rows) > len(best_rows):
best_rows = rows
best_headers = table_headers
if not best_rows:
return {
"headers": ["Турнир", "Дата", "Команды", "Счёт"], "rows": [], "page": page,
"page_size": 30, "total": total, "pages": max(1, math.ceil(total / 30)) if total else 1,
"range_start": start, "range_end": end,
}
stat_count = max((len(row["stats"]) for row in best_rows), default=0)
stat_headers = _match_stat_headers(best_headers, stat_count)
rows_out = []
for row in best_rows:
stats = list(row["stats"])
if len(stats) < stat_count:
stats.extend([""] * (stat_count - len(stats)))
rows_out.append({
"section": row.get("section") or "",
"values": [row.get("section") or "", *row["prefix"], *stats[:stat_count]],
"url": row.get("url") or "",
})
if total <= 0:
total = (page - 1) * 30 + len(rows_out)
pages = max(1, math.ceil(total / 30))
if not start and rows_out:
start = (page - 1) * 30 + 1
if not end and rows_out:
end = start + len(rows_out) - 1
return {
"headers": ["Турнир", "Дата", "Команды", "Счёт", *stat_headers],
"rows": rows_out,
"page": page, "page_size": 30, "total": total, "pages": pages,
"range_start": start, "range_end": end,
}
def get_khl_site_player_matches(khl_id: int, page: int = 1) -> dict[str, Any]:
page = max(1, int(page))
PLAYER_MATCHES_CACHE_DIR.mkdir(parents=True, exist_ok=True)
cache_file = PLAYER_MATCHES_CACHE_DIR / f"{khl_id}_{page}.json"
now = time.time()
# Матчи текущего сезона могут обновляться; суток достаточно, а ручная
# перезагрузка страницы всегда сможет получить свежую страницу после удаления кэша.
try:
cached = read_json(cache_file, {})
fetched_at = float(cached.get("fetched_at") or 0) if isinstance(cached, dict) else 0
payload = cached.get("payload") if isinstance(cached, dict) else None
if fetched_at and now - fetched_at < 24 * 3600 and isinstance(payload, dict):
return payload
except Exception:
pass
html = _download_khl_player_page(khl_id, page)
payload = parse_khl_player_matches_html(html, page)
tmp = cache_file.with_suffix(".tmp")
try:
tmp.write_text(json.dumps({"fetched_at": now, "payload": payload}, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
tmp.replace(cache_file)
except OSError as exc:
print(f"Не удалось записать кэш матчей KHL {khl_id}/{page}: {exc}", file=sys.stderr)
return payload
def enrich_player_item_from_khl_site(item: dict[str, Any]) -> dict[str, Any]:
profile = item.get("_profile_full")
if not isinstance(profile, dict):
profile = dict(item.get("profile") or {})
item["_profile_full"] = profile
normalize_player_profile_fields(profile)
identity = item.get("identity") or {}
khl_id_raw = identity.get("khl_id") or profile.get("khl_id")
try:
khl_id = int(khl_id_raw)
except (TypeError, ValueError):
khl_id = 0
required = ("birthday", "country", "height", "weight", "stick")
missing = [key for key in required if profile.get(key) in (None, "", [], {})]
if khl_id > 0 and missing:
site_fields = get_khl_site_player_profile(khl_id)
for key, value in site_fields.items():
if profile.get(key) in (None, "", [], {}) and value not in (None, "", [], {}):
profile[key] = value
normalize_player_profile_fields(profile)
item["_site_profile_fields"] = sorted(site_fields)
return item
def build_full_player_profile(player: dict[str, Any]) -> tuple[dict[str, Any], str | None, bool]:
"""Собирает подробный профиль из общего объекта и самого свежего информативного этапа."""
profile = json.loads(json.dumps(player.get("profile") or {}, ensure_ascii=False))
stages = player.get("stages") or {}
latest_id: str | None = None
latest_data: dict[str, Any] = {}
for stage_id in sorted(stages, key=_numeric_stage_sort, reverse=True):
data = (stages.get(stage_id) or {}).get("data") or {}
if not isinstance(data, dict):
continue
if latest_id is None and data:
latest_id = str(stage_id)
latest_data = data
for key in (
"age", "role", "role_key", "country", "nationality", "citizenship",
"flag_image_url", "birthday", "birth_date", "date_of_birth", "height",
"weight", "stick", "shoots", "shirt_number", "team", "teams",
"seasons_count",
):
if profile.get(key) in (None, "", [], {}) and data.get(key) not in (None, "", [], {}):
profile[key] = json.loads(json.dumps(data[key], ensure_ascii=False))
identity = player.get("identity") or {}
identity_en = player.get("identity_en") or {}
profile_en = player.get("profile_en") or {}
english_name = (
profile.get("name_en")
or profile_en.get("name")
or identity_en.get("full_name")
or identity_en.get("name")
or None
)
if english_name and not profile.get("name_en"):
profile["name_en"] = english_name
normalize_player_profile_fields(profile)
role_text = str(profile.get("role") or latest_data.get("role") or "").casefold()
role_key = str(profile.get("role_key") or latest_data.get("role_key") or "").casefold()
is_goalie = "врат" in role_text or role_key in {"goalkeeper", "goalie", "gk"}
return profile, latest_id, is_goalie
class DataStore:
def __init__(self) -> None:
self.lock = threading.RLock()
self.players_data: dict[str, Any] = {}
self.coaches_data: dict[str, Any] = {}
self.officials_data: dict[str, Any] = {}
self.players: dict[str, dict[str, Any]] = {}
self.coaches: dict[str, dict[str, Any]] = {}
self.officials: dict[str, dict[str, Any]] = {}
self.player_stages: dict[str, dict[str, Any]] = {}
self.reload()
def reload(self) -> None:
with self.lock:
self.players_data = read_json(PLAYERS_FILE, {"players": [], "stages": [], "meta": {}})
self.coaches_data = read_json(COACHES_FILE, {"people": [], "meta": {}})
self.officials_data = read_json(OFFICIALS_FILE, {"people": [], "meta": {}})
self.player_stages = {}
for raw_stage in self.players_data.get("stages", []):
if raw_stage.get("id") is None:
continue
stage = json.loads(json.dumps(raw_stage, ensure_ascii=False))
if not stage.get("season"):
resolved_season = stage_season(stage)
if resolved_season:
stage["season"] = resolved_season
self.player_stages[str(stage.get("id"))] = stage
self.players = {}
for idx, p in enumerate(self.players_data.get("players", [])):
identity = p.get("identity") or {}
profile = p.get("profile") or {}
pid = str(identity.get("id") or profile.get("id") or f"index-{idx}")
self.players[pid] = p
self.coaches = {str(x.get("id")): x for x in self.coaches_data.get("people", []) if x.get("id") is not None}
self.officials = {str(x.get("id")): x for x in self.officials_data.get("people", []) if x.get("id") is not None}
def broken_player_name_count(self) -> int:
with self.lock:
return sum(1 for item_id, item in self.players.items() if player_sidebar_name(item, item_id).startswith("Игрок #"))
def counts(self) -> dict[str, int]:
with self.lock:
return {"players": len(self.players), "coaches": len(self.coaches), "officials": len(self.officials)}
def filters(self) -> dict[str, Any]:
with self.lock:
player_seasons = sorted({str(s.get("season")) for s in self.player_stages.values() if s.get("season")})
player_roles = set()
for p in self.players.values():
for r in (p.get("stages") or {}).values():
role = (r.get("data") or {}).get("role")
if role:
player_roles.add(str(role))
result: dict[str, Any] = {"players": {"seasons": player_seasons, "roles": sorted(player_roles)}}
for kind, mapping in (("coaches", self.coaches), ("officials", self.officials)):
seasons, roles, teams = set(), set(), set()
for item in mapping.values():
profile = item.get("profile") or {}
seasons.update(profile.get("seasons") or [])
for app in item.get("appearances") or []:
context = app.get("context") or {}
if context.get("season"):
seasons.add(str(context["season"]))
if context.get("role"):
roles.add(str(context["role"]))
for team in app.get("team_links") or []:
if team.get("title"):
teams.add(str(team["title"]))
result[kind] = {"seasons": sorted(seasons), "roles": sorted(roles), "teams": sorted(teams)}
return result
def mapping(self, kind: str) -> dict[str, dict[str, Any]]:
return {"players": self.players, "coaches": self.coaches, "officials": self.officials}[kind]
def search(self, kind: str, q: str, season: str, extra: str, page: int, limit: int) -> dict[str, Any]:
with self.lock:
mapping = self.mapping(kind)
q_cf = q.strip().casefold()
items = []
for item_id, item in mapping.items():
if kind == "players":
identity, profile = item.get("identity") or {}, item.get("profile") or {}
name = player_sidebar_name(item, item_id)
identity_en = item.get("identity_en") or {}
profile_en = item.get("profile_en") or {}
name_en = str(profile.get("name_en") or profile_en.get("name") or identity_en.get("full_name") or "")
khl_id = identity.get("khl_id") or profile.get("khl_id")
stages = item.get("stages") or {}
roles = {str((x.get("data") or {}).get("role")) for x in stages.values() if (x.get("data") or {}).get("role")}
profile_role = str(profile.get("role") or "").strip()
if profile_role.casefold() in {"вратарь", "защитник", "нападающий"}:
roles.add(profile_role)
seasons = {str(self.player_stages.get(str(sid), {}).get("season")) for sid in stages if self.player_stages.get(str(sid), {}).get("season")}
if season and season not in seasons:
continue
if extra and extra not in roles:
continue
image = profile.get("image") or profile.get("photo")
role = sorted(roles)[0] if roles else ""
subtitle = f"KHL {khl_id}" if khl_id else ""
count = f"{len(stages)} этапов"
searchable = f"{name} {name_en} {item_id} {khl_id or ''}".casefold()
else:
profile = item.get("profile") or {}
name = staff_display_name(item, "ru")
name_en = staff_display_name(item, "en")
original_name = str(item.get("name_ru") or profile.get("name") or item.get("name") or "")
appearances = item.get("appearances") or []
seasons = set(profile.get("seasons") or [])
roles, teams = set(), set()
for app in appearances:
ctx = app.get("context") or {}
if ctx.get("season"):
seasons.add(str(ctx["season"]))
if ctx.get("role"):
roles.add(str(ctx["role"]))
for team in app.get("team_links") or []:
if team.get("title"):
teams.add(str(team["title"]))
if season and season not in seasons:
continue
if extra and extra not in (teams if kind == "coaches" else roles):
continue
photos = profile.get("photos") or item.get("photos") or []
image = valid_person_image(photos[0]) if photos else None
role = ", ".join(sorted(roles))
subtitle = next(iter(sorted(teams)), "") if kind == "coaches" else ""
count = f"{len(appearances)} записей"
searchable = f"{name} {name_en} {original_name} {item_id} {' '.join(seasons)} {' '.join(teams)}".casefold()
if q_cf and q_cf not in searchable:
continue
items.append({"id": item_id, "name": name, "name_en": name_en, "image": image, "role": role, "subtitle": subtitle, "count": count})
items.sort(key=lambda x: x["name"].casefold())
total = len(items)
pages = max(1, math.ceil(total / limit))
page = min(max(1, page), pages)
start = (page - 1) * limit
return {"items": items[start:start + limit], "total": total, "page": page, "pages": pages}
def item(self, kind: str, item_id: str) -> dict[str, Any] | None:
with self.lock:
source = self.mapping(kind).get(item_id)
if source is None:
return None
item = json.loads(json.dumps(source, ensure_ascii=False))
if kind == "players":
for sid, record in (item.get("stages") or {}).items():
stage_meta = self.player_stages.get(str(sid), {"id": sid})
record["stage_label"] = stage_label(stage_meta)
record["stage_meta"] = json.loads(json.dumps(stage_meta, ensure_ascii=False))
full_profile, latest_stage_id, is_goalie = build_full_player_profile(item)
item["_profile_full"] = full_profile
item["_latest_stage_id"] = latest_stage_id
item["_is_goalie"] = is_goalie
item["_name_en"] = str(
full_profile.get("name_en")
or (item.get("identity_en") or {}).get("full_name")
or ""
)
computed_aggregates = {mode: aggregate_player(item, self.player_stages, mode) for mode in ("all", "regular", "playoff", "hope")}
exact_aggregates = item.get("site_aggregates") or {}
item["aggregates"] = {
mode: (json.loads(json.dumps(exact_aggregates[mode], ensure_ascii=False)) if (exact_aggregates.get(mode) or {}).get("stats") else computed_aggregates[mode])
for mode in ("all", "regular", "playoff", "hope")
}
else:
item["_display_name"] = staff_display_name(item, "ru")
item["_display_name_en"] = staff_display_name(item, "en")
return item
STORE = DataStore()
class JobManager:
def __init__(self) -> None:
self.lock = threading.Lock()
self.running = False
self.title = ""
self.message = ""
self.return_code: int | None = None
self.started_at: str | None = None
self.finished_at: str | None = None
def state(self) -> dict[str, Any]:
with self.lock:
return {"running": self.running, "title": self.title, "message": self.message, "return_code": self.return_code, "started_at": self.started_at, "finished_at": self.finished_at}
def start(self, title: str, commands: list[list[str]]) -> None:
with self.lock:
if self.running:
raise RuntimeError("Уже выполняется другое обновление")
self.running = True; self.title = title; self.message = "Запуск…"; self.return_code = None; self.started_at = datetime.now().isoformat(timespec="seconds"); self.finished_at = None
threading.Thread(target=self._run, args=(commands,), daemon=True).start()
def _run(self, commands: list[list[str]]) -> None:
code = 0
try:
for command in commands:
with self.lock:
self.message = " ".join(Path(x).name if i in (0, 1) else x for i, x in enumerate(command))
child_env = os.environ.copy()
child_env["PYTHONIOENCODING"] = "utf-8"
child_env["PYTHONUTF8"] = "1"
process = subprocess.Popen(command, cwd=ROOT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", bufsize=1, env=child_env)
assert process.stdout is not None
for line in process.stdout:
text = line.strip()
if text:
with self.lock:
self.message = text[-500:]
code = process.wait()
if code != 0:
break
except Exception as exc:
code = 1
with self.lock:
self.message = str(exc)
finally:
STORE.reload()
with self.lock:
self.running = False; self.return_code = code; self.finished_at = datetime.now().isoformat(timespec="seconds")
self.message = "Готово" if code == 0 else f"Завершено с ошибкой {code}: {self.message}"
JOBS = JobManager()
@APP.get("/", response_class=HTMLResponse)
def root() -> str:
try:
return INDEX_FILE.read_text(encoding="utf-8")
except OSError:
return HTML
@APP.get("/api/status")
def api_status() -> dict[str, Any]:
return {
"counts": STORE.counts(),
"broken_player_names": STORE.broken_player_name_count(),
"filters": STORE.filters(),
"files": {"players": str(PLAYERS_FILE), "coaches": str(COACHES_FILE), "officials": str(OFFICIALS_FILE)},
}
@APP.get("/api/list/{kind}")
def api_list(kind: str, q: str = "", season: str = "", extra: str = "", page: int = 1, limit: int = Query(100, ge=10, le=250)) -> dict[str, Any]:
if kind not in {"players", "coaches", "officials"}:
raise HTTPException(404, "Неизвестный раздел")
return STORE.search(kind, q, season, extra, page, limit)
@APP.get("/api/item/{kind}/{item_id}")
def api_item(kind: str, item_id: str) -> dict[str, Any]:
if kind not in {"players", "coaches", "officials"}:
raise HTTPException(404, "Неизвестный раздел")
item = STORE.item(kind, item_id)
if item is None:
raise HTTPException(404, "Запись не найдена")
# Важно: карточка должна открываться только из локального JSON.
# Внешний KHL.ru больше никогда не блокирует переключение игрока.
return item
@APP.get("/api/player-extra/{item_id}")
def api_player_extra(item_id: str) -> dict[str, Any]:
source = STORE.players.get(str(item_id))
if source is None:
raise HTTPException(404, "Игрок не найден")
identity = source.get("identity") or {}
profile = source.get("profile") or {}
khl_id_raw = identity.get("khl_id") or profile.get("khl_id")
try:
khl_id = int(khl_id_raw)
except (TypeError, ValueError):
raise HTTPException(404, "У игрока нет KHL ID")
try:
fields = get_khl_site_player_profile(khl_id)
except Exception as exc:
raise HTTPException(502, f"Не удалось получить профиль KHL.ru: {exc}") from exc
return {"item_id": str(item_id), "khl_id": khl_id, "fields": fields}
@APP.get("/api/player-matches/{item_id}")
def api_player_matches(item_id: str, page: int = Query(1, ge=1, le=1000)) -> dict[str, Any]:
source = STORE.players.get(str(item_id))
if source is None:
raise HTTPException(404, "Игрок не найден")
identity = source.get("identity") or {}
profile = source.get("profile") or {}
khl_id_raw = identity.get("khl_id") or profile.get("khl_id")
try:
khl_id = int(khl_id_raw)
except (TypeError, ValueError):
raise HTTPException(404, "У игрока нет KHL ID")
try:
payload = get_khl_site_player_matches(khl_id, page)
except Exception as exc:
raise HTTPException(502, f"Не удалось получить матчи KHL.ru: {exc}") from exc
payload = dict(payload)
payload["item_id"] = str(item_id)
payload["khl_id"] = khl_id
return payload
@APP.post("/api/reload")
def api_reload() -> dict[str, Any]:
STORE.reload()
return {"ok": True, "counts": STORE.counts()}
@APP.get("/api/job")
def api_job() -> dict[str, Any]:
return JOBS.state()
@APP.post("/api/collect/{kind}")
def api_collect(kind: str) -> dict[str, Any]:
python = sys.executable
collector = str(ROOT / "khl_site_collector.py")
base_cmd = [python, collector, "--output-dir", str(DATA_DIR), "--cache-dir", str(ROOT / "cache" / "khl_site_html"), "--workers", "10"]
if kind == "players":
title, commands = "KHL.ru · обновление игроков", [base_cmd + ["--kind", "players"]]
elif kind == "coaches":
title, commands = "KHL.ru · обновление тренеров", [base_cmd + ["--kind", "coaches"]]
elif kind in {"officials-current", "staff-current"}:
title, commands = "KHL.ru · текущие судьи", [base_cmd + ["--kind", "officials", "--current-only"]]
elif kind in {"officials-history", "staff-history"}:
title, commands = "KHL.ru · все судьи", [base_cmd + ["--kind", "officials"]]
elif kind == "all":
title, commands = "KHL.ru · полное обновление", [base_cmd + ["--kind", "all"]]
else:
raise HTTPException(404, "Неизвестный тип обновления")
try:
JOBS.start(title, commands)
except RuntimeError as exc:
raise HTTPException(409, str(exc)) from exc
return {"ok": True, "job": JOBS.state()}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Единый центр данных KHL")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8765)
parser.add_argument("--no-browser", action="store_true")
return parser.parse_args()
def main() -> int:
args = parse_args()
if not args.no_browser:
threading.Timer(1.0, lambda: webbrowser.open(f"http://{args.host}:{args.port}/")).start()
uvicorn.run(APP, host=args.host, port=args.port, log_level="info")
return 0
if __name__ == "__main__":
raise SystemExit(main())