LichessStatTgWeb/LichessWebServices/stats_service.py

744 lines
34 KiB
Python
Raw Normal View History

"""
Lichess Statistics API - Сервис обработки статистики
Этот модуль содержит класс StatsService для обработки и агрегации данных
от Lichess API. Включает в себя:
- Парсинг и обработку активности пользователей
- Агрегацию статистики игр по режимам
- Обработку статистики решения задач (пазлов)
- Фильтрацию данных по временным периодам
- Расчет рейтинговых изменений
Автор: Lichess Web Services Team
Версия: 1.0.0
"""
from typing import List, Dict, Any, Optional
from datetime import datetime, timedelta, date
from lichess_client import LichessClient
from models import UserStats, TaskStats, GameModeStats, GamesStats, ActivityResponse, GameStats, GamesOfPeriodStats, GamesOfPeriodResponse, PuzzleStats, PuzzleOfPeriodResponse
import logging
# Настройка логирования для модуля
logger = logging.getLogger(__name__)
class StatsService:
"""
Сервис для обработки и агрегации статистики Lichess.
Предоставляет методы для:
- Получения статистики за разные периоды (сегодня, вчера, неделя)
- Обработки игр за произвольный период
- Анализа активности по решению задач
- Агрегации данных по режимам игр
"""
def __init__(self):
"""
Инициализация сервиса статистики.
Создает экземпляр LichessClient для взаимодействия с API.
"""
self.lichess_client = LichessClient()
# =============================================================================
# ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ ДЛЯ ОБРАБОТКИ ДАННЫХ
# =============================================================================
def _parse_lichess_interval(self, interval: Dict[str, int]) -> date:
"""
Парсит дату из временного интервала Lichess.
Lichess API возвращает временные интервалы в миллисекундах,
этот метод конвертирует их в объект date.
Args:
interval: Словарь с ключом 'start' содержащим timestamp в миллисекундах
Returns:
Объект date с датой активности
"""
# Lichess использует миллисекунды, конвертируем в секунды
timestamp = interval['start'] / 1000
return datetime.fromtimestamp(timestamp).date()
def _is_date_in_range(self, target_date: date, activity_date: date, days_back: int) -> bool:
"""
Проверяет, попадает ли дата активности в нужный диапазон.
Args:
target_date: Целевая дата (обычно сегодня)
activity_date: Дата активности из Lichess
days_back: Количество дней назад для проверки
Returns:
True, если дата активности попадает в диапазон
"""
today = date.today()
start_date = today - timedelta(days=days_back-1)
return start_date <= activity_date <= today
def _calculate_rating_change(self, mode_data: Dict[str, Any]) -> int:
"""
Вычисляет изменение рейтинга для режима игры.
Args:
mode_data: Данные режима игры из Lichess API
Returns:
Изменение рейтинга (может быть отрицательным)
"""
rp = mode_data.get('rp', {})
before = rp.get('before', 0) # Рейтинг до периода
after = rp.get('after', 0) # Рейтинг после периода
return after - before
def _get_final_rating(self, mode_data: Dict[str, Any]) -> int:
"""
Получает финальный рейтинг для режима игры.
Args:
mode_data: Данные режима игры из Lichess API
Returns:
Финальный рейтинг игрока в данном режиме
"""
rp = mode_data.get('rp', {})
return rp.get('after', 0)
def _count_game_results(self, mode_data: Dict[str, Any]) -> Dict[str, int]:
"""
Подсчитывает результаты игр для режима (победы, поражения, ничьи).
Args:
mode_data: Данные режима игры из Lichess API
Returns:
Словарь с количеством побед, поражений и ничьих
"""
wins = mode_data.get('win', 0) # Количество побед
losses = mode_data.get('loss', 0) # Количество поражений
draws = mode_data.get('draw', 0) # Количество ничьих
return {"wins": wins, "losses": losses, "draws": draws}
def _process_games_by_mode(self, games_data: Dict[str, Any]) -> Dict[str, GameModeStats]:
"""
Обрабатывает игры по режимам (bullet, blitz, rapid).
Преобразует сырые данные от Lichess API в структурированную статистику
по каждому режиму игры.
Args:
games_data: Сырые данные игр от Lichess API
Returns:
Словарь с статистикой по каждому режиму игры
"""
result = {}
# Инициализируем все режимы нулевыми значениями
# Это гарантирует, что все режимы будут присутствовать в результате
for mode in ["bullet", "blitz", "rapid", "classical"]:
result[mode] = GameModeStats(
games_played=0,
rating_change=0,
final_rating=0,
wins=0,
losses=0,
draws=0
)
# Обрабатываем данные по режимам
for mode_name, mode_data in games_data.items():
if mode_name in result:
# Извлекаем результаты игр
wins = mode_data.get('win', 0)
losses = mode_data.get('loss', 0)
draws = mode_data.get('draw', 0)
games_played = wins + losses + draws
# Для недельной статистики используем предвычисленные значения
if 'rating_change' in mode_data:
rating_change = mode_data['rating_change']
final_rating = mode_data['final_rating']
else:
# Для дневной статистики вычисляем как обычно
rating_change = self._calculate_rating_change(mode_data)
final_rating = self._get_final_rating(mode_data)
# Создаем объект статистики для режима
result[mode_name] = GameModeStats(
games_played=games_played,
rating_change=rating_change,
final_rating=final_rating,
wins=wins,
losses=losses,
draws=draws
)
return result
def _process_tasks(self, puzzles_data: Dict[str, Any]) -> TaskStats:
"""Обрабатывает статистику задач (пазлов)"""
score = puzzles_data.get('score', {})
wins = score.get('win', 0)
losses = score.get('loss', 0)
draws = score.get('draw', 0)
total = wins + losses + draws
solved = wins
unsolved = losses + draws
return TaskStats(
total=total,
solved=solved,
unsolved=unsolved
)
# =============================================================================
# ПУБЛИЧНЫЕ МЕТОДЫ ДЛЯ ПОЛУЧЕНИЯ СТАТИСТИКИ
# =============================================================================
async def get_today_stats(self, username: str) -> ActivityResponse:
"""
Получает статистику за сегодняшний день.
Анализирует активность пользователя и возвращает статистику игр и задач
за сегодняшний день.
Args:
username: Имя пользователя на Lichess
Returns:
ActivityResponse с данными статистики или сообщением об ошибке
"""
try:
activity_data = await self.lichess_client.get_user_activity(username)
if not activity_data:
return ActivityResponse(
message=f"Пользователь {username} не найден или неактивен"
)
today = date.today()
# Ищем активность за сегодня
today_activity = None
for activity in activity_data:
activity_date = self._parse_lichess_interval(activity['interval'])
if activity_date == today:
today_activity = activity
break
if not today_activity:
return ActivityResponse(
message=f"Активности за сегодняшний день ({today}) не было"
)
# Обрабатываем данные
games_stats = self._process_games_by_mode(today_activity.get('games', {}))
tasks_stats = self._process_tasks(today_activity.get('puzzles', {}))
user_stats = UserStats(
username=username,
tasks=tasks_stats,
games=GamesStats(**games_stats)
)
return ActivityResponse(
message="Статистика за сегодняшний день",
data=user_stats
)
except Exception as e:
logger.error(f"Ошибка при получении статистики за сегодня: {e}")
return ActivityResponse(
message=f"Ошибка при получении статистики: {str(e)}"
)
async def get_yesterday_stats(self, username: str) -> ActivityResponse:
"""
Получает статистику за вчерашний день.
Анализирует активность пользователя и возвращает статистику игр и задач
за вчерашний день.
Args:
username: Имя пользователя на Lichess
Returns:
ActivityResponse с данными статистики или сообщением об ошибке
"""
try:
activity_data = await self.lichess_client.get_user_activity(username)
if not activity_data:
return ActivityResponse(
message=f"Пользователь {username} не найден или неактивен"
)
yesterday = date.today() - timedelta(days=1)
# Ищем активность за вчера
yesterday_activity = None
for activity in activity_data:
activity_date = self._parse_lichess_interval(activity['interval'])
if activity_date == yesterday:
yesterday_activity = activity
break
if not yesterday_activity:
return ActivityResponse(
message=f"Активности за вчерашний день ({yesterday}) не было"
)
# Обрабатываем данные
games_stats = self._process_games_by_mode(yesterday_activity.get('games', {}))
tasks_stats = self._process_tasks(yesterday_activity.get('puzzles', {}))
user_stats = UserStats(
username=username,
tasks=tasks_stats,
games=GamesStats(**games_stats)
)
return ActivityResponse(
message="Статистика за вчерашний день",
data=user_stats
)
except Exception as e:
logger.error(f"Ошибка при получении статистики за вчера: {e}")
return ActivityResponse(
message=f"Ошибка при получении статистики: {str(e)}"
)
async def get_week_stats(self, username: str) -> ActivityResponse:
"""
Получает статистику за последние 7 дней.
Анализирует активность пользователя и возвращает агрегированную статистику
игр и задач за последние 7 дней.
Args:
username: Имя пользователя на Lichess
Returns:
ActivityResponse с данными статистики или сообщением об ошибке
"""
try:
activity_data = await self.lichess_client.get_user_activity(username)
if not activity_data:
return ActivityResponse(
message=f"Пользователь {username} не найден или неактивен"
)
today = date.today()
week_activities = []
# Фильтруем активности за последние 7 дней
for activity in activity_data:
activity_date = self._parse_lichess_interval(activity['interval'])
if self._is_date_in_range(activity_date, activity_date, 7):
week_activities.append(activity)
if not week_activities:
return ActivityResponse(
message="Активности за последние 7 дней не было"
)
# Объединяем все игры и задачи за неделю
combined_games = {}
combined_puzzles = {}
for activity in week_activities:
# Суммируем игры по режимам
for mode, mode_data in activity.get('games', {}).items():
if mode not in combined_games:
combined_games[mode] = {
'win': 0, 'loss': 0, 'draw': 0,
'rating_change': 0, # Суммируем изменения рейтинга
'final_rating': 0 # Берем последний рейтинг
}
combined_games[mode]['win'] += mode_data.get('win', 0)
combined_games[mode]['loss'] += mode_data.get('loss', 0)
combined_games[mode]['draw'] += mode_data.get('draw', 0)
# Суммируем изменения рейтинга (delta = after - before)
rp = mode_data.get('rp', {})
before = rp.get('before', 0)
after = rp.get('after', 0)
delta = after - before
combined_games[mode]['rating_change'] += delta
# Для финального рейтинга берем последнее значение
combined_games[mode]['final_rating'] = after
# Суммируем задачи
puzzles_score = activity.get('puzzles', {}).get('score', {})
if not combined_puzzles:
combined_puzzles = {'score': {'win': 0, 'loss': 0, 'draw': 0}}
combined_puzzles['score']['win'] += puzzles_score.get('win', 0)
combined_puzzles['score']['loss'] += puzzles_score.get('loss', 0)
combined_puzzles['score']['draw'] += puzzles_score.get('draw', 0)
# Обрабатываем данные
games_stats = self._process_games_by_mode(combined_games)
tasks_stats = self._process_tasks(combined_puzzles)
user_stats = UserStats(
username=username,
tasks=tasks_stats,
games=GamesStats(**games_stats)
)
return ActivityResponse(
message="Статистика за последние 7 дней",
data=user_stats
)
except Exception as e:
logger.error(f"Ошибка при получении статистики за неделю: {e}")
return ActivityResponse(
message=f"Ошибка при получении статистики: {str(e)}"
)
def _determine_game_result(self, game: Dict[str, Any], username: str) -> str:
"""
Определяет результат игры для указанного пользователя
Returns:
'win', 'loss', 'draw' или 'unknown'
"""
winner = game.get('winner')
players = game.get('players', {})
# Определяем цвет игрока
user_color = None
if players.get('white', {}).get('user', {}).get('name') == username:
user_color = 'white'
elif players.get('black', {}).get('user', {}).get('name') == username:
user_color = 'black'
if user_color is None:
return 'unknown'
# Определяем результат
if winner is None:
return 'draw'
elif winner == user_color:
return 'win'
else:
return 'loss'
def _get_rating_change(self, game: Dict[str, Any], username: str) -> int:
"""
Получает изменение рейтинга для указанного пользователя
"""
players = game.get('players', {})
# Определяем цвет игрока
user_color = None
if players.get('white', {}).get('user', {}).get('name') == username:
user_color = 'white'
elif players.get('black', {}).get('user', {}).get('name') == username:
user_color = 'black'
if user_color is None:
return 0
# Получаем изменение рейтинга
rating_diff = players.get(user_color, {}).get('ratingDiff')
return rating_diff if rating_diff is not None else 0
def _get_rating_info(self, game: Dict[str, Any], username: str) -> tuple[int, int]:
"""
Получает изменение рейтинга и итоговый рейтинг для указанного пользователя
Returns:
tuple: (rating_change, final_rating)
"""
players = game.get('players', {})
# Определяем цвет игрока (без учета регистра)
user_color = None
white_user = players.get('white', {}).get('user', {})
black_user = players.get('black', {}).get('user', {})
if white_user.get('name', '').lower() == username.lower():
user_color = 'white'
elif black_user.get('name', '').lower() == username.lower():
user_color = 'black'
if user_color is None:
return 0, 0
# Получаем рейтинг до партии и изменение рейтинга
player_data = players.get(user_color, {})
rating_before = player_data.get('rating', 0)
rating_diff = player_data.get('ratingDiff', 0)
# Вычисляем итоговый рейтинг: rating + ratingDiff
final_rating = rating_before + rating_diff
return rating_diff, final_rating
def _process_games_of_period(self, games: List[Dict[str, Any]], username: str) -> GamesOfPeriodStats:
"""
Обрабатывает игры за период и возвращает статистику
"""
# Инициализируем статистику для всех типов игр
stats = {
'bullet': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None},
'blitz': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None},
'rapid': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None},
'classical': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None},
'correspondence': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None},
'total': {'games_played': 0, 'wins': 0, 'losses': 0, 'draws': 0, 'rating_change': 0, 'rating': None}
}
# Сортируем игры по времени создания (от старых к новым) для правильного вычисления итогового рейтинга
sorted_games = sorted(games, key=lambda x: x.get('createdAt', 0))
for game in sorted_games:
speed = game.get('speed', 'unknown')
# Пропускаем неизвестные типы игр
if speed not in stats:
continue
# Определяем результат игры
result = self._determine_game_result(game, username)
if result == 'unknown':
continue
# Получаем изменение рейтинга и итоговый рейтинг
rating_change, final_rating = self._get_rating_info(game, username)
# Обновляем статистику для конкретного типа
stats[speed]['games_played'] += 1
if result == 'win':
stats[speed]['wins'] += 1
elif result == 'loss':
stats[speed]['losses'] += 1
elif result == 'draw':
stats[speed]['draws'] += 1
stats[speed]['rating_change'] += rating_change
# Сохраняем итоговый рейтинг после последней игры
if final_rating is not None:
stats[speed]['rating'] = final_rating
# Обновляем общую статистику
stats['total']['games_played'] += 1
if result == 'win':
stats['total']['wins'] += 1
elif result == 'loss':
stats['total']['losses'] += 1
elif result == 'draw':
stats['total']['draws'] += 1
stats['total']['rating_change'] += rating_change
# Для общей статистики берем рейтинг из последней игры (любого типа)
if final_rating is not None:
stats['total']['rating'] = final_rating
# Создаем объекты GameStats, устанавливая rating только для режимов с играми
def create_game_stats(mode_stats):
# Устанавливаем rating только если были игры
if mode_stats['games_played'] > 0 and mode_stats['rating'] is not None:
return GameStats(**mode_stats)
else:
# Убираем rating для режимов без игр
mode_stats_copy = mode_stats.copy()
mode_stats_copy['rating'] = None
return GameStats(**mode_stats_copy)
return GamesOfPeriodStats(
bullet=create_game_stats(stats['bullet']),
blitz=create_game_stats(stats['blitz']),
rapid=create_game_stats(stats['rapid']),
classical=create_game_stats(stats['classical']),
correspondence=create_game_stats(stats['correspondence']),
total=create_game_stats(stats['total'])
)
async def get_games_of_period(self, username: str, since_timestamp: int, until_timestamp: int, rated_only: bool = True) -> GamesOfPeriodResponse:
"""
Получает статистику игр пользователя за определенный период.
Получает игры от Lichess API за указанный период, обрабатывает их
и возвращает агрегированную статистику по режимам игр.
Args:
username: Имя пользователя на Lichess
since_timestamp: Начало периода (Unix timestamp в секундах)
until_timestamp: Конец периода (Unix timestamp в секундах)
rated_only: Только рейтинговые игры (по умолчанию True)
Returns:
GamesOfPeriodResponse с статистикой игр
"""
try:
# Конвертируем timestamp в миллисекунды для API Lichess
since_ms = since_timestamp * 1000
until_ms = until_timestamp * 1000
# Получаем игры
games = await self.lichess_client.get_games_of_period(username, since_ms, until_ms, rated_only)
if games is None:
return GamesOfPeriodResponse(
message=f"Пользователь {username} не найден",
username=username,
period_start=since_timestamp,
period_end=until_timestamp,
games_count=0
)
if not games:
return GamesOfPeriodResponse(
message=f"Игры за указанный период не найдены",
username=username,
period_start=since_timestamp,
period_end=until_timestamp,
games_count=0
)
# Обрабатываем игры
games_stats = self._process_games_of_period(games, username)
2025-11-16 13:24:39 +03:00
# Определяем время самой старой партии (в секундах)
earliest_game_ts = None
try:
if games:
earliest_game_ts = min(g.get('createdAt', 0) for g in games if isinstance(g.get('createdAt', None), int))
if earliest_game_ts:
earliest_game_ts = earliest_game_ts // 1000
except Exception:
earliest_game_ts = None
return GamesOfPeriodResponse(
message="Статистика игр за период",
username=username,
period_start=since_timestamp,
period_end=until_timestamp,
games_count=len(games),
2025-11-16 13:24:39 +03:00
earliest_game_ts=earliest_game_ts,
data=games_stats
)
except Exception as e:
logger.error(f"Ошибка при получении статистики игр за период: {e}")
return GamesOfPeriodResponse(
message=f"Ошибка при получении статистики: {str(e)}",
username=username,
period_start=since_timestamp,
period_end=until_timestamp,
games_count=0
)
def _process_puzzle_activities(self, activities: List[Dict[str, Any]], since_ms: int, until_ms: int) -> PuzzleStats:
"""
Обрабатывает активности по задачам и возвращает статистику за период
"""
puzzles_in_period = []
for i, activity in enumerate(activities):
# Lichess API использует поле 'date' вместо 'createdAt'
created_at = activity.get('date')
if created_at is None:
if i < 3: # Логируем только первые 3
logger.warning(f"Активность {i} не имеет date: {list(activity.keys())}")
continue
# Логируем первую активность для отладки
if i == 0:
logger.info(f"Первая активность: date={created_at}, since={since_ms}, until={until_ms}")
# Фильтруем по периоду [since_ms, until_ms)
if since_ms <= created_at < until_ms:
puzzles_in_period.append(activity)
logger.info(f"Найдено {len(puzzles_in_period)} активностей в периоде из {len(activities)}")
# Подсчитываем статистику
total_attempts = len(puzzles_in_period)
solved = sum(1 for activity in puzzles_in_period if activity.get('win', False))
failed = total_attempts - solved
success_rate = (solved / total_attempts * 100) if total_attempts > 0 else 0.0
return PuzzleStats(
total_attempts=total_attempts,
solved=solved,
failed=failed,
success_rate=round(success_rate, 2)
)
async def get_puzzle_of_period(self, token: str, since_ms: int, until_ms: int, max_puzzles: int = 50) -> PuzzleOfPeriodResponse:
"""
Получает статистику решения задач за определенный период.
Получает активность по решению задач от Lichess API, фильтрует по периоду
и возвращает агрегированную статистику решения задач.
Args:
token: Bearer токен авторизации от Lichess
since_ms: Начало периода (Unix timestamp в миллисекундах)
until_ms: Конец периода (Unix timestamp в миллисекундах)
max_puzzles: Максимальное количество задач для получения (по умолчанию 50)
Returns:
PuzzleOfPeriodResponse с статистикой решения задач
"""
try:
# Получаем активности по задачам
activities = await self.lichess_client.get_puzzle_activity(token, max_puzzles)
if activities is None:
return PuzzleOfPeriodResponse(
message="Неверный токен авторизации или доступ запрещен",
period_start=since_ms,
period_end=until_ms,
max_puzzles=max_puzzles,
puzzles_in_period=0
)
if not activities:
return PuzzleOfPeriodResponse(
message="Активности по задачам не найдены",
period_start=since_ms,
period_end=until_ms,
max_puzzles=max_puzzles,
puzzles_in_period=0
)
# Обрабатываем активности
puzzle_stats = self._process_puzzle_activities(activities, since_ms, until_ms)
return PuzzleOfPeriodResponse(
message="Статистика решения задач за период",
period_start=since_ms,
period_end=until_ms,
max_puzzles=max_puzzles,
puzzles_in_period=puzzle_stats.total_attempts,
data=puzzle_stats
)
except Exception as e:
logger.error(f"Ошибка при получении статистики решения задач за период: {e}")
return PuzzleOfPeriodResponse(
message=f"Ошибка при получении статистики: {str(e)}",
period_start=since_ms,
period_end=until_ms,
max_puzzles=max_puzzles,
puzzles_in_period=0
)
async def close(self):
"""
Закрывает сервис статистики.
Освобождает ресурсы и корректно закрывает HTTP клиент.
Должен вызываться при завершении работы с сервисом.
"""
await self.lichess_client.close()