1416 lines
94 KiB
Python
1416 lines
94 KiB
Python
#!/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('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"').replaceAll("'",''');
|
||
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&¬Empty(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())
|