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

184
hockey_data/shots_parser.py Normal file
View File

@@ -0,0 +1,184 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any, Iterable
@dataclass(slots=True)
class ParsedShots:
home: int
away: int
strategy: str
def _json_value(content: bytes | str) -> Any:
text = content.decode("utf-8-sig") if isinstance(content, bytes) else str(content).lstrip("\ufeff")
return json.loads(text)
def _key(value: Any) -> str:
return "".join(ch for ch in str(value or "").casefold() if ch.isalnum())
def _number(value: Any) -> int | None:
if isinstance(value, bool) or value is None or value == "":
return None
try:
number = float(str(value).replace(",", "."))
except (TypeError, ValueError):
return None
if number < 0:
return None
return int(number)
def _dict_numeric_count(row: Any) -> int | None:
if isinstance(row, list):
return len(row)
if not isinstance(row, dict):
return _number(row)
preferred = (
"shots", "shot_count", "shots_count", "total_shots", "total",
"count", "attempts", "shotattempts", "shot_attempts", "value",
)
lowered = {_key(k): v for k, v in row.items()}
for candidate in preferred:
value = lowered.get(_key(candidate))
number = _number(value)
if number is not None:
return number
return None
def _side_value(root: Any, aliases: Iterable[str]) -> int | None:
if not isinstance(root, dict):
return None
lowered = {_key(k): v for k, v in root.items()}
for alias in aliases:
if _key(alias) not in lowered:
continue
raw_value = lowered[_key(alias)]
number = _dict_numeric_count(raw_value)
if number is None and isinstance(raw_value, dict) and raw_value:
keys = [str(key).strip() for key in raw_value]
if all(key.isdigit() for key in keys):
number = len(raw_value)
if number is not None:
return number
return None
def _row_team_values(row: dict[str, Any]) -> list[str]:
values: list[str] = []
for key, value in row.items():
norm = _key(key)
if norm in {
"team", "teamid", "idteam", "club", "clubid", "idclub",
"side", "teamside", "teamname", "clubname", "teamtitle",
}:
if isinstance(value, dict):
values.extend(str(item) for item in value.values() if not isinstance(item, (dict, list)))
else:
values.append(str(value))
return values
def _matches_team(row: dict[str, Any], ids: set[str], names: set[str], side_aliases: set[str]) -> bool:
candidates = {_key(value) for value in _row_team_values(row) if value not in (None, "")}
return bool(candidates & ids or candidates & names or candidates & side_aliases)
def _walk_lists(value: Any) -> Iterable[list[Any]]:
if isinstance(value, list):
yield value
for item in value:
yield from _walk_lists(item)
elif isinstance(value, dict):
for item in value.values():
yield from _walk_lists(item)
def parse_shots_json(
content: bytes | str,
*,
home_ids: Iterable[Any] = (),
away_ids: Iterable[Any] = (),
home_names: Iterable[Any] = (),
away_names: Iterable[Any] = (),
) -> ParsedShots:
"""Extract home/away shot attempts from multiple Stat2TV JSON shapes.
The optional endpoint differs between leagues. Some feeds expose totals by
side, some expose a teams list, while others expose one object per shot.
This parser deliberately accepts all three forms and returns no value when
the payload cannot be mapped unambiguously.
"""
data = _json_value(content)
containers: list[Any] = [data]
if isinstance(data, dict):
for key in ("data", "result", "statistics", "stats", "shots", "totals"):
value = data.get(key)
if isinstance(value, (dict, list)):
containers.append(value)
for root in containers:
home = _side_value(root, ("home", "teamA", "A", "left", "host", "homeTeam"))
away = _side_value(root, ("away", "teamB", "B", "right", "visitor", "guest", "awayTeam"))
if home is not None and away is not None:
return ParsedShots(home=home, away=away, strategy="direct-sides")
home_id_keys = {_key(value) for value in home_ids if str(value or "").strip()}
away_id_keys = {_key(value) for value in away_ids if str(value or "").strip()}
home_name_keys = {_key(value) for value in home_names if str(value or "").strip()}
away_name_keys = {_key(value) for value in away_names if str(value or "").strip()}
home_side_aliases = {_key(value) for value in ("home", "A", "left", "host")}
away_side_aliases = {_key(value) for value in ("away", "B", "right", "visitor", "guest")}
# Team-total rows.
for rows in _walk_lists(data):
mapped_home: int | None = None
mapped_away: int | None = None
for item in rows:
if not isinstance(item, dict):
continue
count = _dict_numeric_count(item)
if count is None:
continue
if _matches_team(item, home_id_keys, home_name_keys, home_side_aliases):
mapped_home = count
elif _matches_team(item, away_id_keys, away_name_keys, away_side_aliases):
mapped_away = count
if mapped_home is not None and mapped_away is not None:
return ParsedShots(home=mapped_home, away=mapped_away, strategy="team-totals")
# Event rows: each mapped row is one shot unless it explicitly carries a
# count greater than one.
for rows in _walk_lists(data):
home_count = 0
away_count = 0
mapped = 0
for item in rows:
if not isinstance(item, dict):
continue
multiplier = _dict_numeric_count(item)
# Event payloads commonly include a generic id/value; those are not
# totals. Count one record unless a shot-specific count is present.
specific_count = None
lowered = {_key(k): v for k, v in item.items()}
for key in ("shotcount", "shotscount", "attempts", "shotattempts"):
specific_count = _number(lowered.get(key))
if specific_count is not None:
break
amount = specific_count if specific_count is not None else 1
if _matches_team(item, home_id_keys, home_name_keys, home_side_aliases):
home_count += amount
mapped += 1
elif _matches_team(item, away_id_keys, away_name_keys, away_side_aliases):
away_count += amount
mapped += 1
if mapped and home_count + away_count:
return ParsedShots(home=home_count, away=away_count, strategy="shot-events")
raise ValueError("Не удалось определить броски хозяев и гостей в shots JSON")