Создание единого проекта Lichess Statistics Ecosystem
- Объединены три проекта в один репозиторий - LichessWebServices - REST API для статистики - LichessClientTG_bot - Telegram бот с поддержкой множества пользователей - LichessWebView - Веб-интерфейс для просмотра пользователей и игроков - Добавлен общий docker-compose.yml для запуска всех сервисов - Добавлен скрипт start.sh для удобного запуска - Добавлен README с полным описанием проекта
This commit is contained in:
commit
a08fc8c962
32 changed files with 4990 additions and 0 deletions
57
.gitignore
vendored
Normal file
57
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Databases
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Node modules (если будут использоваться)
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log
|
||||||
|
|
||||||
|
# Временные файлы
|
||||||
|
*.tmp
|
||||||
|
*.bak
|
||||||
|
*.cache
|
||||||
|
|
||||||
|
# Файлы кеша
|
||||||
|
.cache/
|
||||||
|
|
||||||
54
LichessClientTG_bot/.gitignore
vendored
Normal file
54
LichessClientTG_bot/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Database (commented out to include in git)
|
||||||
|
# *.db
|
||||||
|
# *.sqlite
|
||||||
|
# *.sqlite3
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
.dockerignore
|
||||||
31
LichessClientTG_bot/Dockerfile
Normal file
31
LichessClientTG_bot/Dockerfile
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements first for better caching
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create directory for database
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Expose port (if needed for health checks)
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Run the bot
|
||||||
|
CMD ["python", "bot.py"]
|
||||||
116
LichessClientTG_bot/README.md
Normal file
116
LichessClientTG_bot/README.md
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
# Lichess Telegram Bot
|
||||||
|
|
||||||
|
Телеграм бот для получения статистики игроков Lichess с использованием вашего веб-сервиса.
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- **Добавление пользователей**: Добавление игроков Lichess с токеном или без
|
||||||
|
- **Выбор активного игрока**: Глобальный выбор активного игрока для всех чатов
|
||||||
|
- **Статистика**: Получение статистики за сегодня, вчера и неделю
|
||||||
|
- **Периодические уведомления**: Настройка автоматических уведомлений о активности игрока
|
||||||
|
|
||||||
|
## Команды
|
||||||
|
|
||||||
|
- `/start` - Начать работу с ботом
|
||||||
|
- `/adduser` - Добавить нового игрока Lichess
|
||||||
|
- `/getgamers` - Выбрать активного игрока
|
||||||
|
- `/today` - Статистика за сегодня
|
||||||
|
- `/yesterday` - Статистика за вчера
|
||||||
|
- `/week` - Статистика за неделю
|
||||||
|
- `/setperiod` - Настроить периодические уведомления
|
||||||
|
|
||||||
|
## Установка и запуск
|
||||||
|
|
||||||
|
### С помощью Docker (рекомендуется)
|
||||||
|
|
||||||
|
1. Убедитесь, что ваш Lichess API сервис запущен на `http://localhost:8001`
|
||||||
|
|
||||||
|
2. Запустите бота:
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Проверьте логи:
|
||||||
|
```bash
|
||||||
|
docker-compose logs -f lichess-bot
|
||||||
|
```
|
||||||
|
|
||||||
|
### Локальная установка
|
||||||
|
|
||||||
|
1. Установите зависимости:
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Скопируйте файл конфигурации:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Запустите бота:
|
||||||
|
```bash
|
||||||
|
python bot.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
Основные настройки находятся в файле `config.py`:
|
||||||
|
|
||||||
|
- `TELEGRAM_BOT_TOKEN` - Токен вашего телеграм бота
|
||||||
|
- `LICHESS_STATS_API_BASE_URL` - URL вашего веб-сервиса (по умолчанию `http://localhost:8001`)
|
||||||
|
- `PERIOD_OPTIONS` - Доступные периоды для уведомлений
|
||||||
|
- `POLL_INTERVAL` - Интервал опроса Telegram API (1.0 секунда)
|
||||||
|
- `POLL_TIMEOUT` - Таймаут для Long Polling (30 секунд)
|
||||||
|
- `DROP_PENDING_UPDATES` - Игнорировать накопившиеся обновления при запуске
|
||||||
|
- `ALLOWED_UPDATES` - Типы обновлений для обработки
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
├── bot.py # Основной файл бота
|
||||||
|
├── database.py # Работа с базой данных SQLite
|
||||||
|
├── lichess_api.py # API клиент для Lichess и вашего сервиса
|
||||||
|
├── formatters.py # Форматирование ответов
|
||||||
|
├── config.py # Конфигурация
|
||||||
|
├── requirements.txt # Python зависимости
|
||||||
|
├── Dockerfile # Docker образ
|
||||||
|
├── docker-compose.yml # Docker Compose конфигурация
|
||||||
|
└── README.md # Документация
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
Бот использует следующие endpoints вашего сервиса:
|
||||||
|
|
||||||
|
- `GET /today/{username}` - Статистика за сегодня
|
||||||
|
- `GET /yesterday/{username}` - Статистика за вчера
|
||||||
|
- `GET /week/{username}` - Статистика за неделю
|
||||||
|
- `GET /games/{username}/period` - Игры за период
|
||||||
|
- `GET /puzzle/period` - Задачи за период (требует токен)
|
||||||
|
|
||||||
|
## База данных
|
||||||
|
|
||||||
|
Используется SQLite база данных с таблицами:
|
||||||
|
|
||||||
|
- `gamers` - Игроки Lichess
|
||||||
|
- `chat_active_gamers` - Активные игроки по чатам (не используется для глобального режима)
|
||||||
|
|
||||||
|
## Логирование
|
||||||
|
|
||||||
|
Бот ведет подробные логи всех операций. В Docker контейнере логи можно просмотреть командой:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose logs -f lichess-bot
|
||||||
|
```
|
||||||
|
|
||||||
|
## Мониторинг
|
||||||
|
|
||||||
|
Docker Compose включает health checks для мониторинга состояния сервисов.
|
||||||
|
|
||||||
|
## Поддержка
|
||||||
|
|
||||||
|
При возникновении проблем проверьте:
|
||||||
|
|
||||||
|
1. Запущен ли ваш Lichess API сервис на порту 8001
|
||||||
|
2. Правильность токена телеграм бота
|
||||||
|
3. Логи контейнера на наличие ошибок
|
||||||
520
LichessClientTG_bot/bot.py
Normal file
520
LichessClientTG_bot/bot.py
Normal file
|
|
@ -0,0 +1,520 @@
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
|
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
|
from telegram.ext import (
|
||||||
|
Application, CommandHandler, CallbackQueryHandler,
|
||||||
|
MessageHandler, filters, ContextTypes, ConversationHandler
|
||||||
|
)
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
TELEGRAM_BOT_TOKEN, PERIOD_OPTIONS, POLL_INTERVAL,
|
||||||
|
POLL_TIMEOUT, DROP_PENDING_UPDATES, ALLOWED_UPDATES,
|
||||||
|
LICHESS_STATS_API_BASE_URL
|
||||||
|
)
|
||||||
|
from database import Database
|
||||||
|
from lichess_api import LichessAPI
|
||||||
|
from formatters import StatsFormatter
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
level=logging.INFO
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Conversation states
|
||||||
|
WAITING_FOR_TOKEN, WAITING_FOR_USERNAME = range(2)
|
||||||
|
|
||||||
|
class LichessBot:
|
||||||
|
def __init__(self):
|
||||||
|
self.db = Database()
|
||||||
|
self.lichess_api = LichessAPI()
|
||||||
|
self.periodic_tasks = {} # Store periodic tasks
|
||||||
|
self.period_start_times = {} # Store start times for each gamer
|
||||||
|
self.application = None # Will be set when application is created
|
||||||
|
|
||||||
|
async def start_existing_periodic_tasks(self):
|
||||||
|
"""Start periodic tasks for all user-gamer pairs that have periods set"""
|
||||||
|
try:
|
||||||
|
gamers_with_periods = self.db.get_all_gamers_with_periods()
|
||||||
|
logger.info(f"Found {len(gamers_with_periods)} user-gamer pairs with periodic notifications")
|
||||||
|
|
||||||
|
for gamer in gamers_with_periods:
|
||||||
|
if gamer['period_minutes'] > 0:
|
||||||
|
user_id = gamer['user_id']
|
||||||
|
# Start periodic task with user_id and gamer
|
||||||
|
await self.start_periodic_task(gamer, user_id, gamer['period_minutes'])
|
||||||
|
logger.info(f"Started periodic task for {gamer['username']} (user {user_id}) with period {gamer['period_minutes']} minutes")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error starting existing periodic tasks: {e}")
|
||||||
|
|
||||||
|
async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Start command handler"""
|
||||||
|
# Register user in database
|
||||||
|
user = update.effective_user
|
||||||
|
self.db.add_or_get_telegram_user(
|
||||||
|
user_id=user.id,
|
||||||
|
username=user.username,
|
||||||
|
first_name=user.first_name,
|
||||||
|
last_name=user.last_name
|
||||||
|
)
|
||||||
|
|
||||||
|
await update.message.reply_text(
|
||||||
|
"🎯 Добро пожаловать в Lichess Statistics Bot!\n\n"
|
||||||
|
"Доступные команды:\n"
|
||||||
|
"/adduser - Добавить игрока Lichess для отслеживания\n"
|
||||||
|
"/getgamers - Выбрать активного игрока\n"
|
||||||
|
"/today - Статистика за сегодня\n"
|
||||||
|
"/yesterday - Статистика за вчера\n"
|
||||||
|
"/week - Статистика за неделю\n"
|
||||||
|
"/setperiod - Настроить периодические уведомления"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def adduser_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Start adduser command"""
|
||||||
|
await update.message.reply_text(
|
||||||
|
"🔑 Введите ваш Lichess API token.\n"
|
||||||
|
"Если у вас нет токена, введите 0"
|
||||||
|
)
|
||||||
|
return WAITING_FOR_TOKEN
|
||||||
|
|
||||||
|
async def handle_token(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Handle token input"""
|
||||||
|
token = update.message.text.strip()
|
||||||
|
user_id = update.effective_user.id
|
||||||
|
|
||||||
|
if token == "0":
|
||||||
|
await update.message.reply_text("👤 Введите ваш Lichess username:")
|
||||||
|
return WAITING_FOR_USERNAME
|
||||||
|
else:
|
||||||
|
# Get username from token
|
||||||
|
profile = await self.lichess_api.get_user_profile(token)
|
||||||
|
if profile:
|
||||||
|
username = profile.get('username')
|
||||||
|
if username:
|
||||||
|
# Add gamer to database
|
||||||
|
gamer_id = self.db.add_gamer(username, token)
|
||||||
|
# Link user to gamer
|
||||||
|
self.db.add_user_gamer(user_id, gamer_id)
|
||||||
|
|
||||||
|
# If this is the first gamer for this user, make it active
|
||||||
|
user_gamers = self.db.get_user_gamers(user_id)
|
||||||
|
if len(user_gamers) == 1:
|
||||||
|
self.db.set_user_active_gamer(user_id, gamer_id)
|
||||||
|
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"✅ Игрок {username} успешно добавлен!"
|
||||||
|
)
|
||||||
|
return ConversationHandler.END
|
||||||
|
else:
|
||||||
|
await update.message.reply_text(
|
||||||
|
"❌ Не удалось получить username из токена. Попробуйте еще раз или введите 0 для ввода username вручную."
|
||||||
|
)
|
||||||
|
return WAITING_FOR_TOKEN
|
||||||
|
else:
|
||||||
|
await update.message.reply_text(
|
||||||
|
"❌ Неверный токен. Попробуйте еще раз или введите 0 для ввода username вручную."
|
||||||
|
)
|
||||||
|
return WAITING_FOR_TOKEN
|
||||||
|
|
||||||
|
async def handle_username(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Handle username input"""
|
||||||
|
username = update.message.text.strip()
|
||||||
|
user_id = update.effective_user.id
|
||||||
|
|
||||||
|
if username:
|
||||||
|
# Add gamer to database
|
||||||
|
gamer_id = self.db.add_gamer(username)
|
||||||
|
# Link user to gamer
|
||||||
|
self.db.add_user_gamer(user_id, gamer_id)
|
||||||
|
|
||||||
|
# If this is the first gamer for this user, make it active
|
||||||
|
user_gamers = self.db.get_user_gamers(user_id)
|
||||||
|
if len(user_gamers) == 1:
|
||||||
|
self.db.set_user_active_gamer(user_id, gamer_id)
|
||||||
|
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"✅ Игрок {username} успешно добавлен!"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await update.message.reply_text(
|
||||||
|
"❌ Username не может быть пустым. Попробуйте еще раз."
|
||||||
|
)
|
||||||
|
return WAITING_FOR_USERNAME
|
||||||
|
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
async def getgamers(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Get all gamers for the current user and allow selection"""
|
||||||
|
user_id = update.effective_user.id
|
||||||
|
gamers = self.db.get_user_gamers(user_id)
|
||||||
|
|
||||||
|
if not gamers:
|
||||||
|
await update.message.reply_text("📭 В базе нет игроков. Используйте /adduser для добавления.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Show loading message
|
||||||
|
loading_msg = await update.message.reply_text("🔄 Загружаем рейтинги игроков...")
|
||||||
|
|
||||||
|
# Prepare data for each gamer
|
||||||
|
gamers_data = []
|
||||||
|
for gamer in gamers:
|
||||||
|
status = "🟢" if gamer['is_active'] else "⚪"
|
||||||
|
username = gamer['username']
|
||||||
|
|
||||||
|
# Get user ratings from Lichess API
|
||||||
|
ratings_data = await self.lichess_api.get_user_ratings(username)
|
||||||
|
|
||||||
|
if ratings_data and 'perfs' in ratings_data:
|
||||||
|
perfs = ratings_data['perfs']
|
||||||
|
bullet_rating = perfs.get('bullet', {}).get('rating', 'N/A')
|
||||||
|
blitz_rating = perfs.get('blitz', {}).get('rating', 'N/A')
|
||||||
|
rapid_rating = perfs.get('rapid', {}).get('rating', 'N/A')
|
||||||
|
else:
|
||||||
|
bullet_rating = blitz_rating = rapid_rating = 'N/A'
|
||||||
|
|
||||||
|
# Add period information if period > 0
|
||||||
|
period_minutes = gamer.get('period_minutes', 0)
|
||||||
|
period_text = f" · {period_minutes}м" if period_minutes > 0 else ""
|
||||||
|
|
||||||
|
gamers_data.append({
|
||||||
|
'id': gamer['id'],
|
||||||
|
'status': status,
|
||||||
|
'username': username,
|
||||||
|
'bullet': bullet_rating,
|
||||||
|
'blitz': blitz_rating,
|
||||||
|
'rapid': rapid_rating,
|
||||||
|
'period': period_text
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create text message with stats
|
||||||
|
text_lines = []
|
||||||
|
for gamer in gamers_data:
|
||||||
|
text_lines.append(
|
||||||
|
f"{gamer['status']} <b>{gamer['username']}</b> "
|
||||||
|
f"⚡ {gamer['bullet']} 🔥 {gamer['blitz']} 🐇 {gamer['rapid']}{gamer['period']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
gamers_text = "👥 <b>Выберите активного игрока:</b>\n\n" + "\n".join(text_lines)
|
||||||
|
|
||||||
|
# Create simple keyboard with just usernames
|
||||||
|
keyboard = []
|
||||||
|
for gamer in gamers_data:
|
||||||
|
keyboard.append([InlineKeyboardButton(
|
||||||
|
text=f"{gamer['status']} {gamer['username']}",
|
||||||
|
callback_data=f"select_{gamer['id']}"
|
||||||
|
)])
|
||||||
|
|
||||||
|
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||||
|
|
||||||
|
# Edit the loading message with the results
|
||||||
|
await loading_msg.edit_text(
|
||||||
|
gamers_text,
|
||||||
|
parse_mode='HTML',
|
||||||
|
reply_markup=reply_markup
|
||||||
|
)
|
||||||
|
|
||||||
|
async def select_gamer(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Handle gamer selection"""
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
user_id = query.from_user.id
|
||||||
|
gamer_id = int(query.data.split('_')[1])
|
||||||
|
|
||||||
|
# Get user gamers to find the selected one
|
||||||
|
gamers = self.db.get_user_gamers(user_id)
|
||||||
|
selected_gamer = next((g for g in gamers if g['id'] == gamer_id), None)
|
||||||
|
|
||||||
|
if selected_gamer:
|
||||||
|
# Set active gamer for this user
|
||||||
|
self.db.set_user_active_gamer(user_id, gamer_id)
|
||||||
|
await query.edit_message_text(
|
||||||
|
f"✅ Активный игрок: {selected_gamer['username']}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await query.edit_message_text("❌ Игрок не найден")
|
||||||
|
|
||||||
|
async def get_stats(self, update: Update, context: ContextTypes.DEFAULT_TYPE, period: str):
|
||||||
|
"""Get statistics for a period"""
|
||||||
|
user_id = update.effective_user.id
|
||||||
|
|
||||||
|
# Get active gamer for this user
|
||||||
|
active_gamer = self.db.get_user_active_gamer(user_id)
|
||||||
|
|
||||||
|
if not active_gamer:
|
||||||
|
await update.message.reply_text(
|
||||||
|
"❌ Нет активного игрока. Используйте /getgamers для выбора."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
username = active_gamer['username']
|
||||||
|
|
||||||
|
# Get stats based on period
|
||||||
|
if period == "today":
|
||||||
|
data = await self.lichess_api.get_today_stats(username)
|
||||||
|
elif period == "yesterday":
|
||||||
|
data = await self.lichess_api.get_yesterday_stats(username)
|
||||||
|
elif period == "week":
|
||||||
|
data = await self.lichess_api.get_week_stats(username)
|
||||||
|
else:
|
||||||
|
await update.message.reply_text("❌ Неизвестный период")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Format and send response
|
||||||
|
formatted_response = StatsFormatter.format_stats_response(data, username, period)
|
||||||
|
await update.message.reply_text(formatted_response)
|
||||||
|
|
||||||
|
async def today(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Today command"""
|
||||||
|
await self.get_stats(update, context, "today")
|
||||||
|
|
||||||
|
async def yesterday(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Yesterday command"""
|
||||||
|
await self.get_stats(update, context, "yesterday")
|
||||||
|
|
||||||
|
async def week(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Week command"""
|
||||||
|
await self.get_stats(update, context, "week")
|
||||||
|
|
||||||
|
async def setperiod(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Set period command"""
|
||||||
|
user_id = update.effective_user.id
|
||||||
|
|
||||||
|
# Get active gamer for this user
|
||||||
|
active_gamer = self.db.get_user_active_gamer(user_id)
|
||||||
|
|
||||||
|
if not active_gamer:
|
||||||
|
await update.message.reply_text(
|
||||||
|
"❌ Нет активного игрока. Используйте /getgamers для выбора."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
keyboard = []
|
||||||
|
for period in PERIOD_OPTIONS:
|
||||||
|
if period == 0:
|
||||||
|
button_text = "❌ Отключить уведомления"
|
||||||
|
else:
|
||||||
|
button_text = f"⏰ {period} минут"
|
||||||
|
keyboard.append([InlineKeyboardButton(button_text, callback_data=f"period_{period}")])
|
||||||
|
|
||||||
|
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"⏱️ Выберите период для игрока {active_gamer['username']}:\n"
|
||||||
|
f"📱 Уведомления будут приходить в личные сообщения",
|
||||||
|
reply_markup=reply_markup
|
||||||
|
)
|
||||||
|
|
||||||
|
async def select_period(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Handle period selection"""
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
user_id = query.from_user.id
|
||||||
|
period = int(query.data.split('_')[1])
|
||||||
|
|
||||||
|
# Get active gamer for this user
|
||||||
|
active_gamer = self.db.get_user_active_gamer(user_id)
|
||||||
|
|
||||||
|
if active_gamer:
|
||||||
|
# Set period for this user-gamer pair
|
||||||
|
self.db.set_user_gamer_period(user_id, active_gamer['id'], period)
|
||||||
|
|
||||||
|
if period == 0:
|
||||||
|
await query.edit_message_text(
|
||||||
|
f"✅ Уведомления для {active_gamer['username']} отключены"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await query.edit_message_text(
|
||||||
|
f"✅ Период {period} минут установлен для {active_gamer['username']}\n"
|
||||||
|
f"📱 Уведомления будут приходить в личные сообщения"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start periodic task for this gamer (send to user's personal messages)
|
||||||
|
await self.start_periodic_task(active_gamer, user_id, period)
|
||||||
|
|
||||||
|
async def start_periodic_task(self, gamer: Dict[str, Any], user_id: int, period_minutes: int):
|
||||||
|
"""Start periodic task for a gamer"""
|
||||||
|
task_key = f"{gamer['id']}_{user_id}"
|
||||||
|
|
||||||
|
# Cancel existing task if any
|
||||||
|
if task_key in self.periodic_tasks:
|
||||||
|
self.periodic_tasks[task_key].cancel()
|
||||||
|
logger.info(f"Cancelled existing periodic task for {gamer['username']}")
|
||||||
|
|
||||||
|
# Remove old start time
|
||||||
|
if task_key in self.period_start_times:
|
||||||
|
del self.period_start_times[task_key]
|
||||||
|
|
||||||
|
# Create new periodic task
|
||||||
|
task = asyncio.create_task(
|
||||||
|
self.periodic_check(gamer, user_id, period_minutes)
|
||||||
|
)
|
||||||
|
self.periodic_tasks[task_key] = task
|
||||||
|
|
||||||
|
async def periodic_check(self, gamer: Dict[str, Any], user_id: int, period_minutes: int):
|
||||||
|
"""Periodic check for gamer activity"""
|
||||||
|
task_key = f"{gamer['id']}_{user_id}"
|
||||||
|
|
||||||
|
# Запоминаем время начала отслеживания
|
||||||
|
start_time = datetime.now()
|
||||||
|
self.period_start_times[task_key] = start_time
|
||||||
|
logger.info(f"Started periodic monitoring for {gamer['username']} with {period_minutes} minute intervals")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# Ждем заданное количество минут
|
||||||
|
await asyncio.sleep(period_minutes * 60)
|
||||||
|
|
||||||
|
# Получаем время начала периода
|
||||||
|
period_start = self.period_start_times.get(task_key, start_time)
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
# Рассчитываем timestamps в миллисекундах
|
||||||
|
since_timestamp = int(period_start.timestamp() * 1000)
|
||||||
|
until_timestamp = int(now.timestamp() * 1000)
|
||||||
|
|
||||||
|
logger.info(f"Checking period for {gamer['username']}: {period_start} to {now}")
|
||||||
|
logger.info(f"Unix timestamps: since={since_timestamp}, until={until_timestamp}")
|
||||||
|
|
||||||
|
# Делаем запросы к API
|
||||||
|
games_url = f"{LICHESS_STATS_API_BASE_URL}/games/{gamer['username']}/period?since={since_timestamp}&until={until_timestamp}"
|
||||||
|
logger.info(f"🎮 GAMES API REQUEST: {games_url}")
|
||||||
|
|
||||||
|
games_data = await self.lichess_api.get_games_period(
|
||||||
|
gamer['username'], since_timestamp, until_timestamp
|
||||||
|
)
|
||||||
|
logger.info(f"Games API response: {games_data}")
|
||||||
|
|
||||||
|
puzzles_data = None
|
||||||
|
if gamer['token']:
|
||||||
|
puzzles_url = f"{LICHESS_STATS_API_BASE_URL}/puzzle/period?since={since_timestamp}&until={until_timestamp}&max=150"
|
||||||
|
logger.info(f"🧩 PUZZLES API REQUEST: {puzzles_url}")
|
||||||
|
|
||||||
|
puzzles_data = await self.lichess_api.get_puzzles_period(
|
||||||
|
gamer['token'], since_timestamp, until_timestamp, max_puzzles=150
|
||||||
|
)
|
||||||
|
logger.info(f"Puzzles API response: {puzzles_data}")
|
||||||
|
else:
|
||||||
|
logger.info(f"No token for {gamer['username']}, skipping puzzles API call")
|
||||||
|
|
||||||
|
# Проверяем наличие реальной активности
|
||||||
|
has_games = False
|
||||||
|
total_games = 0
|
||||||
|
total_losses = 0
|
||||||
|
if games_data and games_data.get('data'):
|
||||||
|
total_games = games_data.get('data', {}).get('total', {}).get('games_played', 0)
|
||||||
|
total_losses = games_data.get('data', {}).get('total', {}).get('losses', 0)
|
||||||
|
has_games = total_games > 0
|
||||||
|
|
||||||
|
has_puzzles = False
|
||||||
|
if puzzles_data and puzzles_data.get('data'):
|
||||||
|
total_puzzles = puzzles_data.get('data', {}).get('total_attempts', 0)
|
||||||
|
has_puzzles = total_puzzles > 0
|
||||||
|
|
||||||
|
# Детальное логирование для отладки
|
||||||
|
logger.info(f"Activity check for {gamer['username']}: has_games={has_games}, has_puzzles={has_puzzles}")
|
||||||
|
if games_data and games_data.get('data'):
|
||||||
|
total_games = games_data.get('data', {}).get('total', {}).get('games_played', 0)
|
||||||
|
total_losses = games_data.get('data', {}).get('total', {}).get('losses', 0)
|
||||||
|
logger.info(f"Games data: total_games={total_games}, total_losses={total_losses}")
|
||||||
|
if puzzles_data and puzzles_data.get('data'):
|
||||||
|
total_puzzles = puzzles_data.get('data', {}).get('total_attempts', 0)
|
||||||
|
logger.info(f"Puzzles data: total_attempts={total_puzzles}")
|
||||||
|
|
||||||
|
# Отправляем уведомление только если есть реальная активность
|
||||||
|
if has_games or has_puzzles:
|
||||||
|
try:
|
||||||
|
notification = StatsFormatter.format_period_notification(
|
||||||
|
gamer['username'], games_data, puzzles_data, period_minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.application:
|
||||||
|
try:
|
||||||
|
await self.application.bot.send_message(
|
||||||
|
chat_id=user_id,
|
||||||
|
text=notification
|
||||||
|
)
|
||||||
|
logger.info(f"Sent periodic notification for {gamer['username']} to user {user_id}")
|
||||||
|
# Обновляем время начала только после успешной отправки уведомления
|
||||||
|
self.period_start_times[task_key] = now
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send notification to user {user_id}: {e}")
|
||||||
|
# Не обновляем время начала при ошибке отправки
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error formatting notification for {gamer['username']}: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||||
|
# Не обновляем время начала при ошибке форматирования
|
||||||
|
else:
|
||||||
|
logger.info(f"No activity found for {gamer['username']} in the last {period_minutes} minutes")
|
||||||
|
# Обновляем время начала даже если нет активности, чтобы не зацикливаться
|
||||||
|
self.period_start_times[task_key] = now
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in periodic check: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(f"Full traceback: {traceback.format_exc()}")
|
||||||
|
|
||||||
|
def setup_handlers(self, application: Application):
|
||||||
|
"""Setup all handlers"""
|
||||||
|
self.application = application # Store application reference
|
||||||
|
# Conversation handler for adduser
|
||||||
|
adduser_conv = ConversationHandler(
|
||||||
|
entry_points=[CommandHandler("adduser", self.adduser_start)],
|
||||||
|
states={
|
||||||
|
WAITING_FOR_TOKEN: [MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_token)],
|
||||||
|
WAITING_FOR_USERNAME: [MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_username)],
|
||||||
|
},
|
||||||
|
fallbacks=[CommandHandler("cancel", lambda u, c: ConversationHandler.END)]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add all handlers
|
||||||
|
application.add_handler(CommandHandler("start", self.start))
|
||||||
|
application.add_handler(adduser_conv)
|
||||||
|
application.add_handler(CommandHandler("getgamers", self.getgamers))
|
||||||
|
application.add_handler(CommandHandler("today", self.today))
|
||||||
|
application.add_handler(CommandHandler("yesterday", self.yesterday))
|
||||||
|
application.add_handler(CommandHandler("week", self.week))
|
||||||
|
application.add_handler(CommandHandler("setperiod", self.setperiod))
|
||||||
|
|
||||||
|
# Callback handlers
|
||||||
|
application.add_handler(CallbackQueryHandler(self.select_gamer, pattern="^select_"))
|
||||||
|
application.add_handler(CallbackQueryHandler(self.select_period, pattern="^period_"))
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main function"""
|
||||||
|
bot = LichessBot()
|
||||||
|
|
||||||
|
# Create application with Long Polling configuration
|
||||||
|
application = Application.builder().token(TELEGRAM_BOT_TOKEN).build()
|
||||||
|
|
||||||
|
# Setup handlers
|
||||||
|
bot.setup_handlers(application)
|
||||||
|
|
||||||
|
# Set application reference for periodic tasks
|
||||||
|
bot.application = application
|
||||||
|
|
||||||
|
# Start periodic tasks for existing gamers (will be called after application starts)
|
||||||
|
async def post_init(app):
|
||||||
|
await bot.start_existing_periodic_tasks()
|
||||||
|
|
||||||
|
application.post_init = post_init
|
||||||
|
|
||||||
|
# Start the bot with Long Polling
|
||||||
|
logger.info("Starting Lichess Statistics Bot with Long Polling...")
|
||||||
|
application.run_polling(
|
||||||
|
poll_interval=POLL_INTERVAL,
|
||||||
|
timeout=POLL_TIMEOUT,
|
||||||
|
drop_pending_updates=DROP_PENDING_UPDATES,
|
||||||
|
allowed_updates=ALLOWED_UPDATES
|
||||||
|
)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
23
LichessClientTG_bot/config.py
Normal file
23
LichessClientTG_bot/config.py
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Telegram Bot Configuration
|
||||||
|
TELEGRAM_BOT_TOKEN = "8241474807:AAGcsLiaE9El63ARmUgrWASgkhcBv8QB1c8"
|
||||||
|
|
||||||
|
# Lichess API Configuration
|
||||||
|
LICHESS_API_BASE_URL = "https://lichess.org/api"
|
||||||
|
LICHESS_STATS_API_BASE_URL = "http://localhost:8001" # For Docker container access
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
DATABASE_PATH = "/app/data/lichess_bot.db"
|
||||||
|
|
||||||
|
# Period options for /setperiod command
|
||||||
|
PERIOD_OPTIONS = [0, 1, 2, 3, 5, 10, 15, 30, 60, 120, 180] # minutes
|
||||||
|
|
||||||
|
# Telegram Bot Long Polling Configuration
|
||||||
|
POLL_INTERVAL = 1.0 # seconds
|
||||||
|
POLL_TIMEOUT = 30 # seconds
|
||||||
|
DROP_PENDING_UPDATES = True
|
||||||
|
ALLOWED_UPDATES = ["message", "callback_query"]
|
||||||
233
LichessClientTG_bot/database.py
Normal file
233
LichessClientTG_bot/database.py
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
import sqlite3
|
||||||
|
import logging
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from config import DATABASE_PATH
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
def __init__(self, db_path: str = DATABASE_PATH):
|
||||||
|
self.db_path = db_path
|
||||||
|
self.init_database()
|
||||||
|
|
||||||
|
def init_database(self):
|
||||||
|
"""Initialize database tables"""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Create telegram_users table (Telegram bot users)
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS telegram_users (
|
||||||
|
user_id INTEGER PRIMARY KEY,
|
||||||
|
username TEXT,
|
||||||
|
first_name TEXT,
|
||||||
|
last_name TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Create gamers table (Lichess players only)
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS gamers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
token TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Create user_gamers table (relationship between users and gamers)
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS user_gamers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
gamer_id INTEGER NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT FALSE,
|
||||||
|
period_minutes INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES telegram_users(user_id),
|
||||||
|
FOREIGN KEY (gamer_id) REFERENCES gamers(id),
|
||||||
|
UNIQUE(user_id, gamer_id)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
logger.info("Database initialized successfully")
|
||||||
|
|
||||||
|
def add_or_get_telegram_user(self, user_id: int, username: Optional[str] = None,
|
||||||
|
first_name: Optional[str] = None, last_name: Optional[str] = None) -> bool:
|
||||||
|
"""Add or update Telegram user"""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("SELECT user_id FROM telegram_users WHERE user_id = ?", (user_id,))
|
||||||
|
existing = cursor.fetchone()
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO telegram_users (user_id, username, first_name, last_name) VALUES (?, ?, ?, ?)",
|
||||||
|
(user_id, username, first_name, last_name)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def add_gamer(self, username: str, token: Optional[str] = None) -> int:
|
||||||
|
"""Add a new gamer to the database (return gamer_id)"""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if gamer already exists
|
||||||
|
cursor.execute("SELECT id FROM gamers WHERE username = ?", (username,))
|
||||||
|
existing = cursor.fetchone()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Update existing gamer token if provided
|
||||||
|
if token:
|
||||||
|
cursor.execute("UPDATE gamers SET token = ? WHERE username = ?", (token, username))
|
||||||
|
gamer_id = existing[0]
|
||||||
|
else:
|
||||||
|
# Add new gamer
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO gamers (username, token) VALUES (?, ?)",
|
||||||
|
(username, token)
|
||||||
|
)
|
||||||
|
gamer_id = cursor.lastrowid
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return gamer_id
|
||||||
|
|
||||||
|
def add_user_gamer(self, user_id: int, gamer_id: int) -> bool:
|
||||||
|
"""Add relationship between user and gamer"""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO user_gamers (user_id, gamer_id) VALUES (?, ?)",
|
||||||
|
(user_id, gamer_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
# Already exists
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_user_gamers(self, user_id: int) -> List[Dict[str, Any]]:
|
||||||
|
"""Get all gamers for a specific user"""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT g.id, g.username, g.token, ug.is_active, ug.period_minutes
|
||||||
|
FROM user_gamers ug
|
||||||
|
JOIN gamers g ON ug.gamer_id = g.id
|
||||||
|
WHERE ug.user_id = ?
|
||||||
|
ORDER BY ug.id
|
||||||
|
''', (user_id,))
|
||||||
|
|
||||||
|
gamers = []
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
gamers.append({
|
||||||
|
'id': row[0],
|
||||||
|
'username': row[1],
|
||||||
|
'token': row[2],
|
||||||
|
'is_active': bool(row[3]),
|
||||||
|
'period_minutes': row[4]
|
||||||
|
})
|
||||||
|
|
||||||
|
return gamers
|
||||||
|
|
||||||
|
def get_user_active_gamer(self, user_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get the active gamer for a specific user"""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT g.id, g.username, g.token
|
||||||
|
FROM user_gamers ug
|
||||||
|
JOIN gamers g ON ug.gamer_id = g.id
|
||||||
|
WHERE ug.user_id = ? AND ug.is_active = TRUE
|
||||||
|
LIMIT 1
|
||||||
|
''', (user_id,))
|
||||||
|
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return {
|
||||||
|
'id': row[0],
|
||||||
|
'username': row[1],
|
||||||
|
'token': row[2]
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_user_active_gamer(self, user_id: int, gamer_id: int):
|
||||||
|
"""Set active gamer for a specific user"""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Deactivate all gamers for this user
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE user_gamers SET is_active = FALSE WHERE user_id = ?",
|
||||||
|
(user_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Activate the selected gamer
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE user_gamers SET is_active = TRUE WHERE user_id = ? AND gamer_id = ?",
|
||||||
|
(user_id, gamer_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def set_user_gamer_period(self, user_id: int, gamer_id: int, period_minutes: int):
|
||||||
|
"""Set period for a gamer for a specific user"""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE user_gamers SET period_minutes = ? WHERE user_id = ? AND gamer_id = ?",
|
||||||
|
(period_minutes, user_id, gamer_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def get_user_gamers_with_periods(self, user_id: int) -> List[Dict[str, Any]]:
|
||||||
|
"""Get all gamers for a user that have periods set"""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT g.id, g.username, g.token, ug.period_minutes
|
||||||
|
FROM user_gamers ug
|
||||||
|
JOIN gamers g ON ug.gamer_id = g.id
|
||||||
|
WHERE ug.user_id = ? AND ug.period_minutes > 0
|
||||||
|
''', (user_id,))
|
||||||
|
|
||||||
|
gamers = []
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
gamers.append({
|
||||||
|
'id': row[0],
|
||||||
|
'username': row[1],
|
||||||
|
'token': row[2],
|
||||||
|
'period_minutes': row[3]
|
||||||
|
})
|
||||||
|
|
||||||
|
return gamers
|
||||||
|
|
||||||
|
def get_all_gamers_with_periods(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Get all user-gamer pairs that have periods set (for periodic checks across all users)"""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT ug.user_id, g.id, g.username, g.token, ug.period_minutes
|
||||||
|
FROM user_gamers ug
|
||||||
|
JOIN gamers g ON ug.gamer_id = g.id
|
||||||
|
WHERE ug.period_minutes > 0
|
||||||
|
''')
|
||||||
|
|
||||||
|
gamers = []
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
gamers.append({
|
||||||
|
'user_id': row[0],
|
||||||
|
'id': row[1],
|
||||||
|
'username': row[2],
|
||||||
|
'token': row[3],
|
||||||
|
'period_minutes': row[4]
|
||||||
|
})
|
||||||
|
|
||||||
|
return gamers
|
||||||
22
LichessClientTG_bot/docker-compose.yml
Normal file
22
LichessClientTG_bot/docker-compose.yml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
services:
|
||||||
|
lichess-bot:
|
||||||
|
build: .
|
||||||
|
container_name: lichess-telegram-bot
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./lichess_bot.db:/app/lichess_bot.db
|
||||||
|
environment:
|
||||||
|
- PYTHONPATH=/app
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:8001/docs', timeout=5)"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
network_mode: "host"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
bot-data:
|
||||||
|
driver: local
|
||||||
155
LichessClientTG_bot/formatters.py
Normal file
155
LichessClientTG_bot/formatters.py
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class StatsFormatter:
|
||||||
|
@staticmethod
|
||||||
|
def _format_rating_change(rating_change: int) -> str:
|
||||||
|
"""Format rating change with colored circles"""
|
||||||
|
if rating_change > 0:
|
||||||
|
return f"🟢 +{rating_change}"
|
||||||
|
elif rating_change < 0:
|
||||||
|
return f"🔴 {rating_change}"
|
||||||
|
else:
|
||||||
|
return f"0"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_stats_response(data: Dict[str, Any], username: str, period: str) -> str:
|
||||||
|
"""Format statistics response according to the template"""
|
||||||
|
if not data or data.get('data') is None:
|
||||||
|
message = data.get('message', 'Нет данных') if data else 'Нет данных'
|
||||||
|
return f"📭 {message}"
|
||||||
|
|
||||||
|
# Extract data from API response
|
||||||
|
api_data = data.get('data', {})
|
||||||
|
tasks = api_data.get('tasks', {})
|
||||||
|
games = api_data.get('games', {})
|
||||||
|
|
||||||
|
# Format date range
|
||||||
|
date_range = StatsFormatter._get_date_range(period)
|
||||||
|
|
||||||
|
# Format tasks section
|
||||||
|
task_text = ""
|
||||||
|
if tasks and tasks.get('total', 0) > 0:
|
||||||
|
total_tasks = tasks.get('total', 0)
|
||||||
|
solved = tasks.get('solved', 0)
|
||||||
|
unsolved = tasks.get('unsolved', 0)
|
||||||
|
task_text = f"🧩 Задачи: {total_tasks} (✅ {solved} - ❌ {unsolved})\n\n"
|
||||||
|
|
||||||
|
# Format games section
|
||||||
|
games_text = ""
|
||||||
|
if games:
|
||||||
|
for game_type, game_data in games.items():
|
||||||
|
if not game_data or game_data.get('games_played', 0) == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get game type emoji
|
||||||
|
emoji = StatsFormatter._get_game_type_emoji(game_type)
|
||||||
|
|
||||||
|
games_count = game_data.get('games_played', 0)
|
||||||
|
rating_change = game_data.get('rating_change', 0)
|
||||||
|
rating = game_data.get('final_rating', 0)
|
||||||
|
wins = game_data.get('wins', 0)
|
||||||
|
losses = game_data.get('losses', 0)
|
||||||
|
draws = game_data.get('draws', 0)
|
||||||
|
|
||||||
|
# Format rating change
|
||||||
|
rating_change_str = StatsFormatter._format_rating_change(rating_change)
|
||||||
|
|
||||||
|
games_text += f"{emoji} {game_type.title()} — {games_count} игр • {rating_change_str}\n"
|
||||||
|
games_text += f"Рейтинг: {rating}\n"
|
||||||
|
games_text += f"✅ Победы: {wins}\n"
|
||||||
|
games_text += f"❌ Поражения: {losses}\n"
|
||||||
|
games_text += f"🤝 Ничьи: {draws}\n\n"
|
||||||
|
|
||||||
|
# Combine all parts
|
||||||
|
result = f"📊 Статистика {username} • {date_range}\n\n"
|
||||||
|
result += task_text
|
||||||
|
result += games_text.rstrip()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_date_range(period: str) -> str:
|
||||||
|
"""Get date range string for the period"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
today = datetime.now()
|
||||||
|
|
||||||
|
if period == "today":
|
||||||
|
return today.strftime("%d.%m.%Y")
|
||||||
|
elif period == "yesterday":
|
||||||
|
yesterday = today - timedelta(days=1)
|
||||||
|
return yesterday.strftime("%d.%m.%Y")
|
||||||
|
elif period == "week":
|
||||||
|
week_ago = today - timedelta(days=7)
|
||||||
|
return f"{week_ago.strftime('%d.%m.%Y')}–{today.strftime('%d.%m.%Y')}"
|
||||||
|
else:
|
||||||
|
return today.strftime("%d.%m.%Y")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_game_type_emoji(game_type: str) -> str:
|
||||||
|
"""Get emoji for game type"""
|
||||||
|
emoji_map = {
|
||||||
|
'bullet': '⚡️',
|
||||||
|
'blitz': '🔥',
|
||||||
|
'rapid': '🐇',
|
||||||
|
'classical': '♟️',
|
||||||
|
'correspondence': '📮'
|
||||||
|
}
|
||||||
|
return emoji_map.get(game_type.lower(), '🎯')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_period_notification(username: str, games_data: Optional[Dict], puzzles_data: Optional[Dict], period_minutes: int) -> str:
|
||||||
|
"""Format notification for periodic checks"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Format period text
|
||||||
|
if period_minutes == 1:
|
||||||
|
period_text = "за 1 минуту"
|
||||||
|
elif period_minutes in [2, 3, 4]:
|
||||||
|
period_text = f"за {period_minutes} минуты"
|
||||||
|
else:
|
||||||
|
period_text = f"за {period_minutes} минут"
|
||||||
|
|
||||||
|
result = f"📊 Статистика {username} • {period_text}\n\n"
|
||||||
|
|
||||||
|
# Format puzzles first (if available and there's actual activity)
|
||||||
|
if puzzles_data and puzzles_data.get('data'):
|
||||||
|
puzzles_info = puzzles_data['data']
|
||||||
|
total_puzzles = puzzles_info.get('total_attempts', 0)
|
||||||
|
solved = puzzles_info.get('solved', 0)
|
||||||
|
failed = puzzles_info.get('failed', 0)
|
||||||
|
|
||||||
|
# Only show tasks section if there's actual activity (not all zeros)
|
||||||
|
if total_puzzles > 0 or solved > 0 or failed > 0:
|
||||||
|
result += f"🧩 Задачи: {total_puzzles} (✅ {solved} - ❌ {failed})\n\n"
|
||||||
|
|
||||||
|
# Format games
|
||||||
|
if games_data and games_data.get('data'):
|
||||||
|
games_info = games_data['data']
|
||||||
|
total_games = games_info.get('total', {}).get('games_played', 0)
|
||||||
|
|
||||||
|
# Show details for each game type if there were games
|
||||||
|
if total_games > 0:
|
||||||
|
for game_type, game_data in games_info.items():
|
||||||
|
if game_type != 'total' and game_data and game_data.get('games_played', 0) > 0:
|
||||||
|
emoji = StatsFormatter._get_game_type_emoji(game_type)
|
||||||
|
games_count = game_data.get('games_played', 0)
|
||||||
|
rating_change = game_data.get('rating_change', 0)
|
||||||
|
rating = game_data.get('rating', 0)
|
||||||
|
wins = game_data.get('wins', 0)
|
||||||
|
losses = game_data.get('losses', 0)
|
||||||
|
draws = game_data.get('draws', 0)
|
||||||
|
|
||||||
|
rating_change_str = StatsFormatter._format_rating_change(rating_change)
|
||||||
|
result += f"{emoji} {game_type.title()} — {games_count} игр • {rating_change_str}\n"
|
||||||
|
result += f"Рейтинг: {rating}\n"
|
||||||
|
result += f"✅ Победы: {wins}\n"
|
||||||
|
result += f"❌ Поражения: {losses}\n"
|
||||||
|
result += f"🤝 Ничьи: {draws}\n\n"
|
||||||
|
|
||||||
|
# If no activity
|
||||||
|
if not (games_data and games_data.get('data')) and not (puzzles_data and puzzles_data.get('data')):
|
||||||
|
result += "📭 Нет активности за этот период"
|
||||||
|
|
||||||
|
return result.rstrip()
|
||||||
135
LichessClientTG_bot/lichess_api.py
Normal file
135
LichessClientTG_bot/lichess_api.py
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import aiohttp
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from config import LICHESS_API_BASE_URL, LICHESS_STATS_API_BASE_URL
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class LichessAPI:
|
||||||
|
def __init__(self):
|
||||||
|
self.lichess_base_url = LICHESS_API_BASE_URL
|
||||||
|
self.stats_base_url = LICHESS_STATS_API_BASE_URL
|
||||||
|
|
||||||
|
async def get_user_profile(self, token: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get user profile from Lichess API using token"""
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(
|
||||||
|
f"{self.lichess_base_url}/account",
|
||||||
|
headers=headers
|
||||||
|
) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
return await response.json()
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to get user profile: {response.status}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting user profile: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_today_stats(self, username: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get today's statistics from our stats API"""
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(
|
||||||
|
f"{self.stats_base_url}/stats/{username}/today"
|
||||||
|
) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
return await response.json()
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to get today stats: {response.status}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting today stats: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_yesterday_stats(self, username: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get yesterday's statistics from our stats API"""
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(
|
||||||
|
f"{self.stats_base_url}/stats/{username}/yesterday"
|
||||||
|
) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
return await response.json()
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to get yesterday stats: {response.status}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting yesterday stats: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_week_stats(self, username: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get week's statistics from our stats API"""
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(
|
||||||
|
f"{self.stats_base_url}/stats/{username}/week"
|
||||||
|
) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
return await response.json()
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to get week stats: {response.status}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting week stats: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_games_period(self, username: str, since: int, until: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get games for a specific period"""
|
||||||
|
try:
|
||||||
|
url = f"{self.stats_base_url}/games/{username}/period"
|
||||||
|
params = {"since": since, "until": until}
|
||||||
|
logger.info(f"🔍 LichessAPI.get_games_period: URL={url}, params={params}")
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(url, params=params) as response:
|
||||||
|
logger.info(f"🔍 LichessAPI.get_games_period: response.status={response.status}")
|
||||||
|
if response.status == 200:
|
||||||
|
result = await response.json()
|
||||||
|
logger.info(f"🔍 LichessAPI.get_games_period: result={result}")
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to get games period: {response.status}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting games period: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_puzzles_period(self, token: str, since: int, until: int, max_puzzles: int = 150) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get puzzles for a specific period"""
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(
|
||||||
|
f"{self.stats_base_url}/puzzle/period",
|
||||||
|
headers=headers,
|
||||||
|
params={"since": since, "until": until, "max": max_puzzles}
|
||||||
|
) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
return await response.json()
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to get puzzles period: {response.status}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting puzzles period: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_user_ratings(self, username: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get user ratings from Lichess API"""
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(
|
||||||
|
f"{self.lichess_base_url}/user/{username}"
|
||||||
|
) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
return await response.json()
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to get user ratings: {response.status}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting user ratings: {e}")
|
||||||
|
return None
|
||||||
4
LichessClientTG_bot/requirements.txt
Normal file
4
LichessClientTG_bot/requirements.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
python-telegram-bot==20.7
|
||||||
|
requests==2.31.0
|
||||||
|
aiohttp==3.9.1
|
||||||
|
python-dotenv==1.0.0
|
||||||
41
LichessClientTG_bot/run.sh
Executable file
41
LichessClientTG_bot/run.sh
Executable file
|
|
@ -0,0 +1,41 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Lichess Telegram Bot Runner Script
|
||||||
|
|
||||||
|
echo "🎯 Lichess Telegram Bot"
|
||||||
|
echo "======================"
|
||||||
|
|
||||||
|
# Check if Docker is running
|
||||||
|
if ! docker info > /dev/null 2>&1; then
|
||||||
|
echo "❌ Docker is not running. Please start Docker first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if the API service is running
|
||||||
|
echo "🔍 Checking Lichess API service..."
|
||||||
|
if curl -s http://localhost:8001/docs > /dev/null 2>&1; then
|
||||||
|
echo "✅ Lichess API service is running on http://localhost:8001"
|
||||||
|
else
|
||||||
|
echo "⚠️ Warning: Lichess API service is not accessible at http://localhost:8001"
|
||||||
|
echo " Make sure your API service is running before starting the bot."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🚀 Starting Lichess Telegram Bot..."
|
||||||
|
|
||||||
|
# Build and start the bot
|
||||||
|
docker-compose up --build -d
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Bot started successfully!"
|
||||||
|
echo ""
|
||||||
|
echo "📊 To view logs:"
|
||||||
|
echo " docker-compose logs -f lichess-bot"
|
||||||
|
echo ""
|
||||||
|
echo "🛑 To stop the bot:"
|
||||||
|
echo " docker-compose down"
|
||||||
|
echo ""
|
||||||
|
echo "🔧 To restart the bot:"
|
||||||
|
echo " docker-compose restart lichess-bot"
|
||||||
|
echo ""
|
||||||
|
echo "📱 Your bot is now running! Find it in Telegram and start chatting."
|
||||||
79
LichessClientTG_bot/view_db.py
Normal file
79
LichessClientTG_bot/view_db.py
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import sqlite3
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
def view_database():
|
||||||
|
"""View database contents"""
|
||||||
|
db_path = "data/lichess_bot.db"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
print("🗄️ СОДЕРЖИМОЕ БАЗЫ ДАННЫХ LICHESS BOT")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# Show tables
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
|
||||||
|
tables = cursor.fetchall()
|
||||||
|
print(f"\n📋 Таблицы в базе: {[table[0] for table in tables]}")
|
||||||
|
|
||||||
|
# Show gamers table
|
||||||
|
print("\n👥 ТАБЛИЦА GAMERS:")
|
||||||
|
print("-" * 30)
|
||||||
|
cursor.execute("SELECT * FROM gamers")
|
||||||
|
gamers = cursor.fetchall()
|
||||||
|
|
||||||
|
if gamers:
|
||||||
|
print("ID | Username | Token | Active | Period | User ID | Created")
|
||||||
|
print("-" * 70)
|
||||||
|
for gamer in gamers:
|
||||||
|
if len(gamer) == 6: # Old format without user_id
|
||||||
|
gamer_id, username, token, is_active, period_minutes, created_at = gamer
|
||||||
|
user_id = "None"
|
||||||
|
else: # New format with user_id
|
||||||
|
gamer_id, username, token, is_active, period_minutes, user_id, created_at = gamer
|
||||||
|
|
||||||
|
token_display = "***" if token else "None"
|
||||||
|
active_display = "✅" if is_active else "❌"
|
||||||
|
user_id_display = str(user_id) if user_id else "None"
|
||||||
|
print(f"{gamer_id:2} | {username:10} | {token_display:6} | {active_display:6} | {period_minutes:6} | {user_id_display:7} | {created_at}")
|
||||||
|
else:
|
||||||
|
print("Таблица пуста")
|
||||||
|
|
||||||
|
# Show chat_active_gamers table
|
||||||
|
print("\n💬 ТАБЛИЦА CHAT_ACTIVE_GAMERS:")
|
||||||
|
print("-" * 35)
|
||||||
|
cursor.execute("SELECT * FROM chat_active_gamers")
|
||||||
|
chat_gamers = cursor.fetchall()
|
||||||
|
|
||||||
|
if chat_gamers:
|
||||||
|
print("Chat ID | Gamer ID")
|
||||||
|
print("-" * 20)
|
||||||
|
for chat_gamer in chat_gamers:
|
||||||
|
chat_id, gamer_id = chat_gamer
|
||||||
|
print(f"{chat_id:7} | {gamer_id:8}")
|
||||||
|
else:
|
||||||
|
print("Таблица пуста")
|
||||||
|
|
||||||
|
# Show database info
|
||||||
|
print(f"\n📊 ИНФОРМАЦИЯ О БАЗЕ:")
|
||||||
|
print("-" * 25)
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM gamers")
|
||||||
|
total_gamers = cursor.fetchone()[0]
|
||||||
|
print(f"Всего игроков: {total_gamers}")
|
||||||
|
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM gamers WHERE is_active = TRUE")
|
||||||
|
active_gamers = cursor.fetchone()[0]
|
||||||
|
print(f"Активных игроков: {active_gamers}")
|
||||||
|
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM gamers WHERE period_minutes > 0")
|
||||||
|
monitored_gamers = cursor.fetchone()[0]
|
||||||
|
print(f"Игроков с периодическими уведомлениями: {monitored_gamers}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Ошибка при чтении базы данных: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
view_database()
|
||||||
58
LichessWebServices/.gitignore
vendored
Normal file
58
LichessWebServices/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
388
LichessWebServices/API_DOCUMENTATION.md
Normal file
388
LichessWebServices/API_DOCUMENTATION.md
Normal file
|
|
@ -0,0 +1,388 @@
|
||||||
|
# Lichess Statistics API - Документация
|
||||||
|
|
||||||
|
## Описание
|
||||||
|
|
||||||
|
Lichess Statistics API предоставляет REST API для получения детальной статистики игроков платформы Lichess. API позволяет получать информацию о играх, рейтингах и решении задач (пазлов) за различные периоды времени.
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- 📊 **Статистика игр** по режимам (Bullet, Blitz, Rapid)
|
||||||
|
- 🧩 **Статистика решения задач** (пазлов)
|
||||||
|
- 📅 **Статистика за разные периоды** (сегодня, вчера, неделя)
|
||||||
|
- 🎯 **Отслеживание изменений рейтинга**
|
||||||
|
- 📈 **Подробная аналитика результатов игр**
|
||||||
|
|
||||||
|
## Режимы игр
|
||||||
|
|
||||||
|
- **Bullet**: Быстрые игры (1-3 минуты)
|
||||||
|
- **Blitz**: Блиц игры (3-10 минут)
|
||||||
|
- **Rapid**: Рапид игры (10+ минут)
|
||||||
|
|
||||||
|
## Базовый URL
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:8001
|
||||||
|
```
|
||||||
|
|
||||||
|
## Эндпоинты
|
||||||
|
|
||||||
|
### 1. Информация об API
|
||||||
|
|
||||||
|
#### GET /
|
||||||
|
Возвращает основную информацию об API и доступных эндпоинтах.
|
||||||
|
|
||||||
|
**Ответ:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Lichess Statistics API",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "REST API для получения статистики игроков Lichess",
|
||||||
|
"endpoints": {
|
||||||
|
"today": "/stats/{username}/today",
|
||||||
|
"yesterday": "/stats/{username}/yesterday",
|
||||||
|
"week": "/stats/{username}/week"
|
||||||
|
},
|
||||||
|
"documentation": "/docs",
|
||||||
|
"openapi_schema": "/openapi.json"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Health Check
|
||||||
|
|
||||||
|
#### GET /health
|
||||||
|
Проверка состояния сервиса.
|
||||||
|
|
||||||
|
**Ответ:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"timestamp": "2024-01-15T10:30:00Z",
|
||||||
|
"service": "Lichess Statistics API"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Статистика за сегодня
|
||||||
|
|
||||||
|
#### GET /stats/{username}/today
|
||||||
|
Получает статистику игрока за сегодняшний день.
|
||||||
|
|
||||||
|
**Параметры:**
|
||||||
|
- `username` (string, обязательный) - имя пользователя на Lichess
|
||||||
|
|
||||||
|
**Пример запроса:**
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8001/stats/magnus/today
|
||||||
|
```
|
||||||
|
|
||||||
|
**Пример ответа:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Статистика за сегодняшний день",
|
||||||
|
"data": {
|
||||||
|
"username": "magnus",
|
||||||
|
"tasks": {
|
||||||
|
"total": 15,
|
||||||
|
"solved": 12,
|
||||||
|
"unsolved": 3
|
||||||
|
},
|
||||||
|
"games": {
|
||||||
|
"bullet": {
|
||||||
|
"games_played": 8,
|
||||||
|
"rating_change": 15,
|
||||||
|
"final_rating": 2850,
|
||||||
|
"wins": 5,
|
||||||
|
"losses": 2,
|
||||||
|
"draws": 1
|
||||||
|
},
|
||||||
|
"blitz": {
|
||||||
|
"games_played": 3,
|
||||||
|
"rating_change": -5,
|
||||||
|
"final_rating": 2750,
|
||||||
|
"wins": 1,
|
||||||
|
"losses": 2,
|
||||||
|
"draws": 0
|
||||||
|
},
|
||||||
|
"rapid": {
|
||||||
|
"games_played": 0,
|
||||||
|
"rating_change": 0,
|
||||||
|
"final_rating": 0,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0,
|
||||||
|
"draws": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Статистика за вчера
|
||||||
|
|
||||||
|
#### GET /stats/{username}/yesterday
|
||||||
|
Получает статистику игрока за вчерашний день.
|
||||||
|
|
||||||
|
**Параметры:**
|
||||||
|
- `username` (string, обязательный) - имя пользователя на Lichess
|
||||||
|
|
||||||
|
**Пример запроса:**
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8001/stats/magnus/yesterday
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Статистика за неделю
|
||||||
|
|
||||||
|
#### GET /stats/{username}/week
|
||||||
|
Получает агрегированную статистику игрока за последние 7 дней.
|
||||||
|
|
||||||
|
**Параметры:**
|
||||||
|
- `username` (string, обязательный) - имя пользователя на Lichess
|
||||||
|
|
||||||
|
**Пример запроса:**
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8001/stats/magnus/week
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Статистика игр за период
|
||||||
|
|
||||||
|
#### GET /games/{username}/period
|
||||||
|
Получает детальную статистику игр пользователя за указанный период времени.
|
||||||
|
|
||||||
|
**Параметры:**
|
||||||
|
- `username` (string, обязательный) - имя пользователя на Lichess
|
||||||
|
- `since` (integer, обязательный) - начало периода (Unix timestamp в миллисекундах)
|
||||||
|
- `until` (integer, обязательный) - конец периода (Unix timestamp в миллисекундах)
|
||||||
|
- `rated_only` (boolean, опциональный) - только рейтинговые игры (по умолчанию true - рекомендуется)
|
||||||
|
|
||||||
|
**Пример запроса:**
|
||||||
|
```bash
|
||||||
|
# Статистика за последние 7 дней
|
||||||
|
curl "http://localhost:8001/games/magnus/period?since=1640995200000&until=1641081600000"
|
||||||
|
|
||||||
|
# Только рейтинговые игры
|
||||||
|
curl "http://localhost:8001/games/magnus/period?since=1640995200000&until=1641081600000&rated_only=true"
|
||||||
|
|
||||||
|
# Все игры (включая нерейтинговые)
|
||||||
|
curl "http://localhost:8001/games/magnus/period?since=1640995200000&until=1641081600000&rated_only=false"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Пример ответа:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Статистика игр за период",
|
||||||
|
"username": "magnus",
|
||||||
|
"period_start": 1640995200000,
|
||||||
|
"period_end": 1641081600000,
|
||||||
|
"games_count": 25,
|
||||||
|
"data": {
|
||||||
|
"bullet": {
|
||||||
|
"games_played": 10,
|
||||||
|
"wins": 6,
|
||||||
|
"losses": 3,
|
||||||
|
"draws": 1,
|
||||||
|
"rating_change": 15
|
||||||
|
},
|
||||||
|
"blitz": {
|
||||||
|
"games_played": 8,
|
||||||
|
"wins": 5,
|
||||||
|
"losses": 2,
|
||||||
|
"draws": 1,
|
||||||
|
"rating_change": 12
|
||||||
|
},
|
||||||
|
"rapid": {
|
||||||
|
"games_played": 5,
|
||||||
|
"wins": 3,
|
||||||
|
"losses": 1,
|
||||||
|
"draws": 1,
|
||||||
|
"rating_change": 8
|
||||||
|
},
|
||||||
|
"classical": {
|
||||||
|
"games_played": 2,
|
||||||
|
"wins": 1,
|
||||||
|
"losses": 1,
|
||||||
|
"draws": 0,
|
||||||
|
"rating_change": 0
|
||||||
|
},
|
||||||
|
"correspondence": {
|
||||||
|
"games_played": 0,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0,
|
||||||
|
"draws": 0,
|
||||||
|
"rating_change": 0
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"games_played": 25,
|
||||||
|
"wins": 15,
|
||||||
|
"losses": 7,
|
||||||
|
"draws": 3,
|
||||||
|
"rating_change": 35
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Статистика решения задач за период
|
||||||
|
|
||||||
|
#### GET /puzzle/period
|
||||||
|
Получает статистику решения задач (пазлов) за указанный период времени. Требует авторизации через Bearer токен.
|
||||||
|
|
||||||
|
**Параметры:**
|
||||||
|
- `since` (integer, обязательный) - начало периода (Unix timestamp в миллисекундах)
|
||||||
|
- `until` (integer, обязательный) - конец периода (Unix timestamp в миллисекундах)
|
||||||
|
- `max` (integer, опциональный) - максимальное количество задач для получения (по умолчанию 50, максимум 1000)
|
||||||
|
- `Authorization` (header, обязательный) - Bearer токен авторизации
|
||||||
|
|
||||||
|
**Важно:** Параметр `max` ограничивает количество активностей, получаемых от Lichess API. Если в указанном периоде было больше задач, чем указано в `max`, то будут показаны только последние N активностей. Для получения полной статистики рекомендуется увеличить значение `max` или использовать значение по умолчанию (50).
|
||||||
|
|
||||||
|
**Пример запроса:**
|
||||||
|
```bash
|
||||||
|
# Статистика за последние 7 дней
|
||||||
|
curl -H "Authorization: Bearer your_token_here" \
|
||||||
|
"http://localhost:8001/puzzle/period?since=1640995200000&until=1641081600000"
|
||||||
|
|
||||||
|
# Больше задач
|
||||||
|
curl -H "Authorization: Bearer your_token_here" \
|
||||||
|
"http://localhost:8001/puzzle/period?since=1640995200000&until=1641081600000&max=100"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Пример ответа:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Статистика решения задач за период",
|
||||||
|
"period_start": 1640995200000,
|
||||||
|
"period_end": 1641081600000,
|
||||||
|
"max_puzzles": 50,
|
||||||
|
"puzzles_in_period": 15,
|
||||||
|
"data": {
|
||||||
|
"total_attempts": 15,
|
||||||
|
"solved": 12,
|
||||||
|
"failed": 3,
|
||||||
|
"success_rate": 80.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Получение токена:**
|
||||||
|
1. Зайдите на https://lichess.org/account/oauth/token/create
|
||||||
|
2. Создайте новый токен с правами на чтение активности
|
||||||
|
3. Используйте токен в заголовке Authorization
|
||||||
|
|
||||||
|
## Модели данных
|
||||||
|
|
||||||
|
### TaskStats
|
||||||
|
Статистика решения задач (пазлов):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total": 15, // Общее количество решенных задач
|
||||||
|
"solved": 12, // Количество правильно решенных задач
|
||||||
|
"unsolved": 3 // Количество нерешенных или неправильно решенных задач
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GameModeStats
|
||||||
|
Статистика игр для конкретного режима:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"games_played": 8, // Общее количество сыгранных игр
|
||||||
|
"rating_change": 15, // Изменение рейтинга (может быть отрицательным)
|
||||||
|
"final_rating": 2850, // Текущий рейтинг игрока
|
||||||
|
"wins": 5, // Количество побед
|
||||||
|
"losses": 2, // Количество поражений
|
||||||
|
"draws": 1 // Количество ничьих
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GamesStats
|
||||||
|
Статистика игр по всем режимам:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"bullet": { /* GameModeStats */ },
|
||||||
|
"blitz": { /* GameModeStats */ },
|
||||||
|
"rapid": { /* GameModeStats */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### UserStats
|
||||||
|
Полная статистика пользователя:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "magnus",
|
||||||
|
"tasks": { /* TaskStats */ },
|
||||||
|
"games": { /* GamesStats */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ActivityResponse
|
||||||
|
Ответ API с результатами запроса:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Статистика за сегодняшний день",
|
||||||
|
"data": { /* UserStats или null */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Коды ошибок
|
||||||
|
|
||||||
|
- **200** - Успешный запрос
|
||||||
|
- **404** - Пользователь не найден или неактивен
|
||||||
|
- **500** - Внутренняя ошибка сервера
|
||||||
|
|
||||||
|
## Примеры ошибок
|
||||||
|
|
||||||
|
### Пользователь не найден
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Пользователь magnus не найден или неактивен"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Внутренняя ошибка сервера
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "Внутренняя ошибка сервера: Connection timeout"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Swagger UI
|
||||||
|
|
||||||
|
Интерактивная документация доступна по адресу:
|
||||||
|
```
|
||||||
|
http://localhost:8001/docs
|
||||||
|
```
|
||||||
|
|
||||||
|
## OpenAPI Schema
|
||||||
|
|
||||||
|
Схема OpenAPI доступна по адресу:
|
||||||
|
```
|
||||||
|
http://localhost:8001/openapi.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Запуск в Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Сборка и запуск
|
||||||
|
docker compose up --build -d
|
||||||
|
|
||||||
|
# Проверка статуса
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# Просмотр логов
|
||||||
|
docker compose logs
|
||||||
|
|
||||||
|
# Остановка
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
## Разработка
|
||||||
|
|
||||||
|
### Установка зависимостей
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Запуск в режиме разработки
|
||||||
|
```bash
|
||||||
|
uvicorn main:app --host 0.0.0.0 --port 8001 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
MIT License
|
||||||
26
LichessWebServices/Dockerfile
Normal file
26
LichessWebServices/Dockerfile
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# Lichess Statistics API - Dockerfile
|
||||||
|
#
|
||||||
|
# Этот Dockerfile создает образ для запуска Lichess Statistics API
|
||||||
|
# в контейнере Docker.
|
||||||
|
|
||||||
|
# Используем официальный Python образ на базе Debian slim
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Устанавливаем рабочую директорию в контейнере
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Копируем файл зависимостей и устанавливаем пакеты
|
||||||
|
# Делаем это отдельно для кэширования слоев Docker
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Копируем весь код приложения
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Открываем порт 8000 для HTTP запросов
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Запускаем FastAPI приложение через Uvicorn
|
||||||
|
# --host 0.0.0.0 позволяет принимать соединения с любого IP
|
||||||
|
# --port 8000 указывает порт для прослушивания
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
121
LichessWebServices/README.md
Normal file
121
LichessWebServices/README.md
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
# Lichess Statistics API
|
||||||
|
|
||||||
|
REST API сервис для получения статистики игроков Lichess.org по играм и решению задач.
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- Получение статистики за сегодняшний день
|
||||||
|
- Получение статистики за вчерашний день
|
||||||
|
- Получение статистики за последние 7 дней
|
||||||
|
- Поддержка режимов игры: Bullet, Blitz, Rapid
|
||||||
|
- Статистика по решению задач (пазлов)
|
||||||
|
- Расчет изменения рейтинга
|
||||||
|
- Подсчет побед, поражений и ничьих
|
||||||
|
|
||||||
|
## Запуск с Docker
|
||||||
|
|
||||||
|
### 1. Сборка и запуск контейнера
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Сборка образа
|
||||||
|
docker-compose build
|
||||||
|
|
||||||
|
# Запуск сервиса
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Проверка работы
|
||||||
|
|
||||||
|
Сервис будет доступен по адресу: http://localhost:8000
|
||||||
|
|
||||||
|
- Документация API: http://localhost:8000/docs
|
||||||
|
- Проверка здоровья: http://localhost:8000/health
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Получить статистику за сегодня
|
||||||
|
```
|
||||||
|
GET /stats/{username}/today
|
||||||
|
```
|
||||||
|
|
||||||
|
### Получить статистику за вчера
|
||||||
|
```
|
||||||
|
GET /stats/{username}/yesterday
|
||||||
|
```
|
||||||
|
|
||||||
|
### Получить статистику за неделю
|
||||||
|
```
|
||||||
|
GET /stats/{username}/week
|
||||||
|
```
|
||||||
|
|
||||||
|
## Примеры использования
|
||||||
|
|
||||||
|
### Статистика за сегодня
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/stats/vrubelroman/today
|
||||||
|
```
|
||||||
|
|
||||||
|
### Статистика за вчера
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/stats/vrubelroman/yesterday
|
||||||
|
```
|
||||||
|
|
||||||
|
### Статистика за неделю
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/stats/vrubelroman/week
|
||||||
|
```
|
||||||
|
|
||||||
|
## Формат ответа
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Статистика за сегодняшний день",
|
||||||
|
"data": {
|
||||||
|
"username": "vrubelroman",
|
||||||
|
"tasks": {
|
||||||
|
"total": 28,
|
||||||
|
"solved": 25,
|
||||||
|
"unsolved": 3
|
||||||
|
},
|
||||||
|
"games": {
|
||||||
|
"bullet": {
|
||||||
|
"games_played": 7,
|
||||||
|
"rating_change": 30,
|
||||||
|
"final_rating": 2320,
|
||||||
|
"wins": 5,
|
||||||
|
"losses": 0,
|
||||||
|
"draws": 0
|
||||||
|
},
|
||||||
|
"blitz": {
|
||||||
|
"games_played": 5,
|
||||||
|
"rating_change": 32,
|
||||||
|
"final_rating": 2224,
|
||||||
|
"wins": 5,
|
||||||
|
"losses": 0,
|
||||||
|
"draws": 0
|
||||||
|
},
|
||||||
|
"rapid": {
|
||||||
|
"games_played": 2,
|
||||||
|
"rating_change": -10,
|
||||||
|
"final_rating": 2210,
|
||||||
|
"wins": 1,
|
||||||
|
"losses": 1,
|
||||||
|
"draws": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Остановка сервиса
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
## Логи
|
||||||
|
|
||||||
|
Для просмотра логов:
|
||||||
|
```bash
|
||||||
|
docker-compose logs -f
|
||||||
|
```
|
||||||
30
LichessWebServices/docker-compose.yml
Normal file
30
LichessWebServices/docker-compose.yml
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Lichess Statistics API - Docker Compose
|
||||||
|
#
|
||||||
|
# Этот файл определяет сервисы для запуска Lichess Statistics API
|
||||||
|
# в контейнере Docker с помощью Docker Compose.
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Основной сервис API
|
||||||
|
lichess-api:
|
||||||
|
# Собираем образ из Dockerfile в текущей директории
|
||||||
|
build: .
|
||||||
|
|
||||||
|
# Маппинг портов: хост:контейнер
|
||||||
|
# 8001 на хосте -> 8000 в контейнере
|
||||||
|
# Изменено с 8000:8000 из-за конфликта портов
|
||||||
|
ports:
|
||||||
|
- "8001:8000"
|
||||||
|
|
||||||
|
# Переменные окружения
|
||||||
|
environment:
|
||||||
|
# Отключаем буферизацию Python для корректного вывода логов
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Монтируем текущую директорию в контейнер для разработки
|
||||||
|
# Это позволяет видеть изменения кода без пересборки образа
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
|
||||||
|
# Политика перезапуска: перезапускать контейнер при сбое
|
||||||
|
# (кроме случаев ручной остановки)
|
||||||
|
restart: unless-stopped
|
||||||
250
LichessWebServices/lichess_client.py
Normal file
250
LichessWebServices/lichess_client.py
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
"""
|
||||||
|
Lichess Statistics API - Клиент для работы с Lichess API
|
||||||
|
|
||||||
|
Этот модуль содержит класс LichessClient для взаимодействия с официальным API Lichess.
|
||||||
|
Обеспечивает:
|
||||||
|
- Получение активности пользователей
|
||||||
|
- Получение игр за период
|
||||||
|
- Получение активности по решению задач (пазлов)
|
||||||
|
- Обработку ошибок и таймаутов
|
||||||
|
- Парсинг NDJSON формата
|
||||||
|
|
||||||
|
Автор: Lichess Web Services Team
|
||||||
|
Версия: 1.0.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Настройка логирования для модуля
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class LichessClient:
|
||||||
|
"""
|
||||||
|
Клиент для взаимодействия с Lichess API.
|
||||||
|
|
||||||
|
Предоставляет методы для получения различных данных от Lichess:
|
||||||
|
- Активность пользователей
|
||||||
|
- Игры за период
|
||||||
|
- Статистика решения задач
|
||||||
|
|
||||||
|
Все методы асинхронные и используют httpx для HTTP запросов.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""
|
||||||
|
Инициализация клиента Lichess API.
|
||||||
|
|
||||||
|
Создает HTTP клиент с таймаутом 30 секунд для всех запросов.
|
||||||
|
"""
|
||||||
|
self.base_url = "https://lichess.org/api" # Базовый URL Lichess API
|
||||||
|
self.client = httpx.AsyncClient(timeout=30.0) # HTTP клиент с таймаутом
|
||||||
|
|
||||||
|
async def get_user_activity(self, username: str) -> Optional[List[Dict[str, Any]]]:
|
||||||
|
"""
|
||||||
|
Получает активность пользователя за последние 7 активных дней.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
username: Имя пользователя на Lichess
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список активностей пользователя или None, если пользователь не найден
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
httpx.HTTPStatusError: При ошибках HTTP (кроме 404)
|
||||||
|
Exception: При других ошибках
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Формируем URL для получения активности пользователя
|
||||||
|
url = f"{self.base_url}/user/{username}/activity"
|
||||||
|
logger.info(f"Запрос активности пользователя {username}")
|
||||||
|
|
||||||
|
# Выполняем HTTP GET запрос
|
||||||
|
response = await self.client.get(url)
|
||||||
|
response.raise_for_status() # Проверяем статус ответа
|
||||||
|
|
||||||
|
# Возвращаем JSON данные
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 404:
|
||||||
|
# Пользователь не найден - это нормальная ситуация
|
||||||
|
logger.warning(f"Пользователь {username} не найден")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
# Другие HTTP ошибки - логируем и пробрасываем
|
||||||
|
logger.error(f"HTTP ошибка при получении активности пользователя {username}: {e}")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
# Обрабатываем все остальные ошибки
|
||||||
|
logger.error(f"Ошибка при получении активности пользователя {username}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def get_games_of_period(self, username: str, since_ms: int, until_ms: int, rated_only: bool = True) -> Optional[List[Dict[str, Any]]]:
|
||||||
|
"""
|
||||||
|
Получает игры пользователя за определенный период.
|
||||||
|
|
||||||
|
Lichess API возвращает игры в формате NDJSON (Newline Delimited JSON),
|
||||||
|
где каждая строка содержит JSON объект с информацией об игре.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
username: Имя пользователя на Lichess
|
||||||
|
since_ms: Начало периода в миллисекундах (Unix timestamp * 1000)
|
||||||
|
until_ms: Конец периода в миллисекундах (Unix timestamp * 1000)
|
||||||
|
rated_only: Только рейтинговые игры (по умолчанию True)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список игр в формате JSON или None при ошибке
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
httpx.HTTPStatusError: При ошибках HTTP
|
||||||
|
Exception: При других ошибках
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Формируем URL для получения игр пользователя
|
||||||
|
url = f"{self.base_url}/games/user/{username}"
|
||||||
|
|
||||||
|
# Параметры запроса
|
||||||
|
params = {
|
||||||
|
'since': since_ms, # Начало периода
|
||||||
|
'until': until_ms, # Конец периода
|
||||||
|
'max': 1000 # Максимум игр за запрос (лимит Lichess API)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Добавляем фильтр по рейтинговым играм, если нужно
|
||||||
|
if rated_only:
|
||||||
|
params['rated'] = 'true'
|
||||||
|
|
||||||
|
# Заголовки для получения NDJSON формата
|
||||||
|
headers = {
|
||||||
|
'Accept': 'application/x-ndjson' # Запрашиваем NDJSON формат
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Запрос игр для {username} с {since_ms} по {until_ms}")
|
||||||
|
|
||||||
|
# Выполняем HTTP GET запрос
|
||||||
|
response = await self.client.get(url, params=params, headers=headers)
|
||||||
|
response.raise_for_status() # Проверяем статус ответа
|
||||||
|
|
||||||
|
# Парсим NDJSON (Newline Delimited JSON)
|
||||||
|
# Каждая строка содержит отдельный JSON объект
|
||||||
|
games = []
|
||||||
|
content = response.text.strip()
|
||||||
|
|
||||||
|
if content:
|
||||||
|
for line in content.split('\n'):
|
||||||
|
if line.strip():
|
||||||
|
try:
|
||||||
|
# Парсим каждую строку как отдельный JSON объект
|
||||||
|
game = json.loads(line)
|
||||||
|
games.append(game)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
# Логируем ошибки парсинга, но продолжаем обработку
|
||||||
|
logger.warning(f"Ошибка парсинга JSON строки: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"Получено {len(games)} игр для пользователя {username}")
|
||||||
|
return games
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 404:
|
||||||
|
# Пользователь не найден - это нормальная ситуация
|
||||||
|
logger.warning(f"Пользователь {username} не найден")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
# Другие HTTP ошибки - логируем и пробрасываем
|
||||||
|
logger.error(f"HTTP ошибка при получении игр пользователя {username}: {e}")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
# Обрабатываем все остальные ошибки
|
||||||
|
logger.error(f"Ошибка при получении игр пользователя {username}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def get_puzzle_activity(self, token: str, max_puzzles: int = 50) -> Optional[List[Dict[str, Any]]]:
|
||||||
|
"""
|
||||||
|
Получает активность пользователя по решению задач (пазлов).
|
||||||
|
|
||||||
|
Требует авторизации через Bearer токен. Lichess API возвращает данные
|
||||||
|
в формате NDJSON (Newline Delimited JSON).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: Bearer токен авторизации от Lichess
|
||||||
|
max_puzzles: Максимальное количество задач для получения (по умолчанию 50)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список активностей по задачам в формате JSON или None при ошибке
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
httpx.HTTPStatusError: При ошибках HTTP
|
||||||
|
Exception: При других ошибках
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Формируем URL для получения активности по задачам
|
||||||
|
url = f"{self.base_url}/puzzle/activity"
|
||||||
|
|
||||||
|
# Параметры запроса
|
||||||
|
params = {
|
||||||
|
'max': max_puzzles # Максимальное количество задач
|
||||||
|
}
|
||||||
|
|
||||||
|
# Заголовки с авторизацией и форматом данных
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'Bearer {token}', # Bearer токен авторизации
|
||||||
|
'Accept': 'application/x-ndjson' # Запрашиваем NDJSON формат
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Запрос активности по задачам, max={max_puzzles}")
|
||||||
|
|
||||||
|
# Выполняем HTTP GET запрос
|
||||||
|
response = await self.client.get(url, params=params, headers=headers)
|
||||||
|
response.raise_for_status() # Проверяем статус ответа
|
||||||
|
|
||||||
|
# Парсим NDJSON (Newline Delimited JSON)
|
||||||
|
# Каждая строка содержит отдельный JSON объект с активностью
|
||||||
|
activities = []
|
||||||
|
content = response.text.strip()
|
||||||
|
|
||||||
|
if content:
|
||||||
|
for line in content.split('\n'):
|
||||||
|
if line.strip():
|
||||||
|
try:
|
||||||
|
# Парсим каждую строку как отдельный JSON объект
|
||||||
|
activity = json.loads(line)
|
||||||
|
activities.append(activity)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
# Логируем ошибки парсинга, но продолжаем обработку
|
||||||
|
logger.warning(f"Ошибка парсинга JSON строки: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"Получено {len(activities)} активностей по задачам")
|
||||||
|
return activities
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 401:
|
||||||
|
# Неверный токен авторизации
|
||||||
|
logger.warning("Неверный токен авторизации")
|
||||||
|
return None
|
||||||
|
elif e.response.status_code == 403:
|
||||||
|
# Доступ запрещен (недостаточно прав)
|
||||||
|
logger.warning("Доступ запрещен")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
# Другие HTTP ошибки - логируем и пробрасываем
|
||||||
|
logger.error(f"HTTP ошибка при получении активности по задачам: {e}")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
# Обрабатываем все остальные ошибки
|
||||||
|
logger.error(f"Ошибка при получении активности по задачам: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
"""
|
||||||
|
Закрывает HTTP клиент.
|
||||||
|
|
||||||
|
Освобождает ресурсы и корректно закрывает соединения.
|
||||||
|
Должен вызываться при завершении работы с клиентом.
|
||||||
|
"""
|
||||||
|
await self.client.aclose()
|
||||||
705
LichessWebServices/main.py
Normal file
705
LichessWebServices/main.py
Normal file
|
|
@ -0,0 +1,705 @@
|
||||||
|
"""
|
||||||
|
Lichess Statistics API - Основной модуль FastAPI приложения
|
||||||
|
|
||||||
|
Этот модуль содержит все API эндпоинты для получения статистики игроков Lichess.
|
||||||
|
Включает в себя:
|
||||||
|
- Статистику игр за разные периоды (сегодня, вчера, неделя)
|
||||||
|
- Детальную статистику игр за произвольный период
|
||||||
|
- Статистику решения задач (пазлов) за период
|
||||||
|
- Health check и информационные эндпоинты
|
||||||
|
|
||||||
|
Автор: Lichess Web Services Team
|
||||||
|
Версия: 1.0.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Path, Query, Header
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from stats_service import StatsService
|
||||||
|
from models import ActivityResponse, ErrorResponse, HealthResponse, GamesOfPeriodResponse, PuzzleOfPeriodResponse
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# НАСТРОЙКА ЛОГИРОВАНИЯ
|
||||||
|
# =============================================================================
|
||||||
|
# Настройка базового логирования для всего приложения
|
||||||
|
# Уровень INFO позволяет видеть все важные события и ошибки
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ГЛОБАЛЬНЫЕ ПЕРЕМЕННЫЕ
|
||||||
|
# =============================================================================
|
||||||
|
# Глобальный экземпляр сервиса статистики
|
||||||
|
# Инициализируется при запуске приложения и используется во всех эндпоинтах
|
||||||
|
stats_service = None
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# УПРАВЛЕНИЕ ЖИЗНЕННЫМ ЦИКЛОМ ПРИЛОЖЕНИЯ
|
||||||
|
# =============================================================================
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""
|
||||||
|
Контекстный менеджер для управления жизненным циклом FastAPI приложения.
|
||||||
|
|
||||||
|
Выполняется при запуске и остановке приложения:
|
||||||
|
- При запуске: инициализирует сервис статистики
|
||||||
|
- При остановке: корректно закрывает все соединения
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app: Экземпляр FastAPI приложения
|
||||||
|
"""
|
||||||
|
# ========== STARTUP (Запуск приложения) ==========
|
||||||
|
global stats_service
|
||||||
|
# Создаем экземпляр сервиса статистики, который будет использоваться во всех эндпоинтах
|
||||||
|
stats_service = StatsService()
|
||||||
|
logger.info("Lichess API сервис запущен")
|
||||||
|
|
||||||
|
# Передаем управление приложению
|
||||||
|
yield
|
||||||
|
|
||||||
|
# ========== SHUTDOWN (Остановка приложения) ==========
|
||||||
|
# Корректно закрываем все соединения и освобождаем ресурсы
|
||||||
|
if stats_service:
|
||||||
|
await stats_service.close()
|
||||||
|
logger.info("Lichess API сервис остановлен")
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# СОЗДАНИЕ FASTAPI ПРИЛОЖЕНИЯ
|
||||||
|
# =============================================================================
|
||||||
|
app = FastAPI(
|
||||||
|
title="Lichess Statistics API",
|
||||||
|
description="""
|
||||||
|
## Lichess Statistics API
|
||||||
|
|
||||||
|
REST API для получения детальной статистики игроков платформы Lichess.
|
||||||
|
|
||||||
|
### Возможности:
|
||||||
|
* 📊 Получение статистики игр по режимам (Bullet, Blitz, Rapid)
|
||||||
|
* 🧩 Статистика решения задач (пазлов)
|
||||||
|
* 📅 Статистика за разные периоды (сегодня, вчера, неделя)
|
||||||
|
* 🎯 Отслеживание изменений рейтинга
|
||||||
|
* 📈 Подробная аналитика результатов игр
|
||||||
|
|
||||||
|
### Режимы игр:
|
||||||
|
- **Bullet**: Быстрые игры (1-3 минуты)
|
||||||
|
- **Blitz**: Блиц игры (3-10 минут)
|
||||||
|
- **Rapid**: Рапид игры (10+ минут)
|
||||||
|
|
||||||
|
### Примеры использования:
|
||||||
|
- Получить статистику за сегодня: `GET /stats/{username}/today`
|
||||||
|
- Получить статистику за вчера: `GET /stats/{username}/yesterday`
|
||||||
|
- Получить статистику за неделю: `GET /stats/{username}/week`
|
||||||
|
""",
|
||||||
|
version="1.0.0",
|
||||||
|
contact={
|
||||||
|
"name": "Lichess Statistics API Support",
|
||||||
|
"url": "https://github.com/vrubelroman/LichessWebServices",
|
||||||
|
},
|
||||||
|
license_info={
|
||||||
|
"name": "MIT",
|
||||||
|
},
|
||||||
|
lifespan=lifespan,
|
||||||
|
openapi_tags=[
|
||||||
|
{
|
||||||
|
"name": "health",
|
||||||
|
"description": "Проверка состояния сервиса"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "statistics",
|
||||||
|
"description": "Получение статистики игроков Lichess"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "info",
|
||||||
|
"description": "Информация об API"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# НАСТРОЙКА CORS (Cross-Origin Resource Sharing)
|
||||||
|
# =============================================================================
|
||||||
|
# CORS middleware позволяет веб-приложениям делать запросы к API с других доменов
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"], # В продакшене следует ограничить домены для безопасности
|
||||||
|
allow_credentials=True, # Разрешаем отправку cookies и авторизационных заголовков
|
||||||
|
allow_methods=["*"], # Разрешаем все HTTP методы (GET, POST, PUT, DELETE и т.д.)
|
||||||
|
allow_headers=["*"], # Разрешаем все заголовки
|
||||||
|
)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# API ЭНДПОИНТЫ
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@app.get("/", tags=["info"])
|
||||||
|
async def root():
|
||||||
|
"""
|
||||||
|
## Корневой endpoint
|
||||||
|
|
||||||
|
Возвращает основную информацию об API и доступных эндпоинтах.
|
||||||
|
Используется для получения базовой информации о сервисе.
|
||||||
|
|
||||||
|
### Возвращает:
|
||||||
|
- Название API
|
||||||
|
- Версию
|
||||||
|
- Список доступных эндпоинтов
|
||||||
|
- Ссылки на документацию
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"message": "Lichess Statistics API",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "REST API для получения статистики игроков Lichess",
|
||||||
|
"endpoints": {
|
||||||
|
"today": "/stats/{username}/today", # Статистика за сегодня
|
||||||
|
"yesterday": "/stats/{username}/yesterday", # Статистика за вчера
|
||||||
|
"week": "/stats/{username}/week", # Статистика за неделю
|
||||||
|
"games_period": "/games/{username}/period?since={timestamp}&until={timestamp}", # Статистика игр за период
|
||||||
|
"puzzle_period": "/puzzle/period?since={timestamp}&until={timestamp}&max={max}" # Статистика задач за период
|
||||||
|
},
|
||||||
|
"documentation": "/docs", # Swagger UI документация
|
||||||
|
"openapi_schema": "/openapi.json" # OpenAPI схема
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/health",
|
||||||
|
response_model=HealthResponse,
|
||||||
|
tags=["health"],
|
||||||
|
summary="Health Check",
|
||||||
|
description="Проверка состояния сервиса")
|
||||||
|
async def health_check():
|
||||||
|
"""
|
||||||
|
## Проверка здоровья сервиса
|
||||||
|
|
||||||
|
Простой endpoint для проверки работоспособности API.
|
||||||
|
Используется для мониторинга и health checks в production среде.
|
||||||
|
|
||||||
|
### Возвращает:
|
||||||
|
- Статус сервиса (healthy/unhealthy)
|
||||||
|
- Время проверки в ISO формате
|
||||||
|
- Название сервиса
|
||||||
|
|
||||||
|
### Использование:
|
||||||
|
- Мониторинг системы
|
||||||
|
- Load balancer health checks
|
||||||
|
- Kubernetes liveness/readiness probes
|
||||||
|
"""
|
||||||
|
return HealthResponse(
|
||||||
|
status="healthy", # Всегда возвращает healthy, если сервис запущен
|
||||||
|
timestamp=datetime.now().isoformat(), # Текущее время в ISO формате
|
||||||
|
service="Lichess Statistics API"
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/stats/{username}/today",
|
||||||
|
response_model=ActivityResponse,
|
||||||
|
tags=["statistics"],
|
||||||
|
summary="Статистика за сегодня",
|
||||||
|
description="Получает детальную статистику игрока за сегодняшний день",
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"description": "Статистика успешно получена",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"example": {
|
||||||
|
"message": "Статистика за сегодняшний день",
|
||||||
|
"data": {
|
||||||
|
"username": "magnus",
|
||||||
|
"tasks": {
|
||||||
|
"total": 15,
|
||||||
|
"solved": 12,
|
||||||
|
"unsolved": 3
|
||||||
|
},
|
||||||
|
"games": {
|
||||||
|
"bullet": {
|
||||||
|
"games_played": 8,
|
||||||
|
"rating_change": 15,
|
||||||
|
"final_rating": 2850,
|
||||||
|
"wins": 5,
|
||||||
|
"losses": 2,
|
||||||
|
"draws": 1
|
||||||
|
},
|
||||||
|
"blitz": {
|
||||||
|
"games_played": 3,
|
||||||
|
"rating_change": -5,
|
||||||
|
"final_rating": 2750,
|
||||||
|
"wins": 1,
|
||||||
|
"losses": 2,
|
||||||
|
"draws": 0
|
||||||
|
},
|
||||||
|
"rapid": {
|
||||||
|
"games_played": 0,
|
||||||
|
"rating_change": 0,
|
||||||
|
"final_rating": 0,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0,
|
||||||
|
"draws": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
"description": "Пользователь не найден",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"example": {
|
||||||
|
"message": "Пользователь magnus не найден или неактивен"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
"description": "Внутренняя ошибка сервера",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"example": {
|
||||||
|
"detail": "Внутренняя ошибка сервера: Connection timeout"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
async def get_today_stats(
|
||||||
|
username: str = Path(...,
|
||||||
|
description="Имя пользователя на Lichess",
|
||||||
|
example="magnus",
|
||||||
|
min_length=1,
|
||||||
|
max_length=50)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
## Статистика за сегодняшний день
|
||||||
|
|
||||||
|
Получает детальную статистику игрока за сегодняшний день, включая:
|
||||||
|
|
||||||
|
### Статистика игр:
|
||||||
|
- **Bullet**: Быстрые игры (1-3 минуты)
|
||||||
|
- **Blitz**: Блиц игры (3-10 минут)
|
||||||
|
- **Rapid**: Рапид игры (10+ минут)
|
||||||
|
|
||||||
|
### Для каждого режима:
|
||||||
|
- Количество сыгранных игр
|
||||||
|
- Изменение рейтинга
|
||||||
|
- Текущий рейтинг
|
||||||
|
- Количество побед, поражений, ничьих
|
||||||
|
|
||||||
|
### Статистика задач:
|
||||||
|
- Общее количество решенных задач
|
||||||
|
- Количество решенных задач
|
||||||
|
- Количество нерешенных задач
|
||||||
|
|
||||||
|
### Параметры:
|
||||||
|
- **username**: Имя пользователя на Lichess (обязательно)
|
||||||
|
|
||||||
|
### Возможные ошибки:
|
||||||
|
- **404**: Пользователь не найден или неактивен
|
||||||
|
- **500**: Внутренняя ошибка сервера
|
||||||
|
"""
|
||||||
|
# Проверяем, что сервис статистики инициализирован
|
||||||
|
if not stats_service:
|
||||||
|
raise HTTPException(status_code=500, detail="Сервис не инициализирован")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Получаем статистику за сегодняшний день через сервис
|
||||||
|
result = await stats_service.get_today_stats(username)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
# Логируем ошибку и возвращаем HTTP 500
|
||||||
|
logger.error(f"Ошибка в endpoint get_today_stats: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
|
||||||
|
|
||||||
|
@app.get("/stats/{username}/yesterday",
|
||||||
|
response_model=ActivityResponse,
|
||||||
|
tags=["statistics"],
|
||||||
|
summary="Статистика за вчера",
|
||||||
|
description="Получает детальную статистику игрока за вчерашний день")
|
||||||
|
async def get_yesterday_stats(
|
||||||
|
username: str = Path(...,
|
||||||
|
description="Имя пользователя на Lichess",
|
||||||
|
example="magnus",
|
||||||
|
min_length=1,
|
||||||
|
max_length=50)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
## Статистика за вчерашний день
|
||||||
|
|
||||||
|
Получает детальную статистику игрока за вчерашний день.
|
||||||
|
|
||||||
|
### Возвращает:
|
||||||
|
- Статистику игр по всем режимам (Bullet, Blitz, Rapid)
|
||||||
|
- Статистику решения задач (пазлов)
|
||||||
|
- Изменения рейтинга
|
||||||
|
- Результаты игр (победы, поражения, ничьи)
|
||||||
|
|
||||||
|
### Параметры:
|
||||||
|
- **username**: Имя пользователя на Lichess (обязательно)
|
||||||
|
"""
|
||||||
|
if not stats_service:
|
||||||
|
raise HTTPException(status_code=500, detail="Сервис не инициализирован")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await stats_service.get_yesterday_stats(username)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка в endpoint get_yesterday_stats: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
|
||||||
|
|
||||||
|
@app.get("/stats/{username}/week",
|
||||||
|
response_model=ActivityResponse,
|
||||||
|
tags=["statistics"],
|
||||||
|
summary="Статистика за неделю",
|
||||||
|
description="Получает агрегированную статистику игрока за последние 7 дней")
|
||||||
|
async def get_week_stats(
|
||||||
|
username: str = Path(...,
|
||||||
|
description="Имя пользователя на Lichess",
|
||||||
|
example="magnus",
|
||||||
|
min_length=1,
|
||||||
|
max_length=50)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
## Статистика за последние 7 дней
|
||||||
|
|
||||||
|
Получает агрегированную статистику игрока за последние 7 дней.
|
||||||
|
|
||||||
|
### Особенности:
|
||||||
|
- Суммирует все игры и задачи за неделю
|
||||||
|
- Показывает общее изменение рейтинга
|
||||||
|
- Отображает финальный рейтинг на конец периода
|
||||||
|
|
||||||
|
### Возвращает:
|
||||||
|
- Общую статистику игр по всем режимам
|
||||||
|
- Суммарную статистику решения задач
|
||||||
|
- Агрегированные изменения рейтинга
|
||||||
|
|
||||||
|
### Параметры:
|
||||||
|
- **username**: Имя пользователя на Lichess (обязательно)
|
||||||
|
"""
|
||||||
|
if not stats_service:
|
||||||
|
raise HTTPException(status_code=500, detail="Сервис не инициализирован")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await stats_service.get_week_stats(username)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка в endpoint get_week_stats: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
|
||||||
|
|
||||||
|
@app.get("/games/{username}/period",
|
||||||
|
response_model=GamesOfPeriodResponse,
|
||||||
|
tags=["statistics"],
|
||||||
|
summary="Статистика игр за период",
|
||||||
|
description="Получает детальную статистику игр пользователя за указанный период",
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"description": "Статистика игр успешно получена",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"example": {
|
||||||
|
"message": "Статистика игр за период",
|
||||||
|
"username": "magnus",
|
||||||
|
"period_start": 1640995200000,
|
||||||
|
"period_end": 1641081600000,
|
||||||
|
"games_count": 25,
|
||||||
|
"data": {
|
||||||
|
"bullet": {
|
||||||
|
"games_played": 10,
|
||||||
|
"wins": 6,
|
||||||
|
"losses": 3,
|
||||||
|
"draws": 1,
|
||||||
|
"rating_change": 15,
|
||||||
|
"rating": 2850
|
||||||
|
},
|
||||||
|
"blitz": {
|
||||||
|
"games_played": 8,
|
||||||
|
"wins": 5,
|
||||||
|
"losses": 2,
|
||||||
|
"draws": 1,
|
||||||
|
"rating_change": 12,
|
||||||
|
"rating": 2750
|
||||||
|
},
|
||||||
|
"rapid": {
|
||||||
|
"games_played": 5,
|
||||||
|
"wins": 3,
|
||||||
|
"losses": 1,
|
||||||
|
"draws": 1,
|
||||||
|
"rating_change": 8,
|
||||||
|
"rating": 2600
|
||||||
|
},
|
||||||
|
"classical": {
|
||||||
|
"games_played": 2,
|
||||||
|
"wins": 1,
|
||||||
|
"losses": 1,
|
||||||
|
"draws": 0,
|
||||||
|
"rating_change": 0,
|
||||||
|
"rating": 2400
|
||||||
|
},
|
||||||
|
"correspondence": {
|
||||||
|
"games_played": 0,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0,
|
||||||
|
"draws": 0,
|
||||||
|
"rating_change": 0,
|
||||||
|
"rating": None
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"games_played": 25,
|
||||||
|
"wins": 15,
|
||||||
|
"losses": 7,
|
||||||
|
"draws": 3,
|
||||||
|
"rating_change": 35,
|
||||||
|
"rating": 2850
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
"description": "Некорректные параметры запроса",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"example": {
|
||||||
|
"detail": "Параметр 'since' должен быть меньше 'until'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
"description": "Пользователь не найден",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"example": {
|
||||||
|
"message": "Пользователь magnus не найден"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
"description": "Внутренняя ошибка сервера",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"example": {
|
||||||
|
"detail": "Внутренняя ошибка сервера: Connection timeout"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
async def get_games_of_period(
|
||||||
|
username: str = Path(...,
|
||||||
|
description="Имя пользователя на Lichess",
|
||||||
|
example="magnus",
|
||||||
|
min_length=1,
|
||||||
|
max_length=50),
|
||||||
|
since: int = Query(...,
|
||||||
|
description="Начало периода (Unix timestamp в миллисекундах)",
|
||||||
|
example=1640995200000),
|
||||||
|
until: int = Query(...,
|
||||||
|
description="Конец периода (Unix timestamp в миллисекундах)",
|
||||||
|
example=1641081600000),
|
||||||
|
rated_only: bool = Query(True,
|
||||||
|
description="Только рейтинговые игры (по умолчанию true - рекомендуется)",
|
||||||
|
example=True)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
## Статистика игр за период
|
||||||
|
|
||||||
|
Получает детальную статистику игр пользователя за указанный период времени.
|
||||||
|
Этот эндпоинт позволяет получить подробную аналитику по играм за любой период.
|
||||||
|
|
||||||
|
### Возможности:
|
||||||
|
- **Фильтрация по времени**: точный период с Unix timestamp
|
||||||
|
- **Типы игр**: Bullet, Blitz, Rapid, Classical, Correspondence
|
||||||
|
- **Статистика результатов**: победы, поражения, ничьи
|
||||||
|
- **Рейтинговые изменения**: суммарные изменения рейтинга
|
||||||
|
- **Итоговый рейтинг**: рейтинг после последней игры в каждом режиме
|
||||||
|
- **Фильтр рейтинговых игр**: только рейтинговые или все игры
|
||||||
|
|
||||||
|
### Параметры:
|
||||||
|
- **username**: Имя пользователя на Lichess (обязательно)
|
||||||
|
- **since**: Начало периода в Unix timestamp (миллисекунды) (обязательно)
|
||||||
|
- **until**: Конец периода в Unix timestamp (миллисекунды) (обязательно)
|
||||||
|
- **rated_only**: Только рейтинговые игры (по умолчанию true - рекомендуется)
|
||||||
|
|
||||||
|
### Примеры использования:
|
||||||
|
- Статистика за последние 7 дней: `since=1640995200000&until=1641081600000` (по умолчанию только рейтинговые)
|
||||||
|
- Только рейтинговые игры: `rated_only=true` (рекомендуется)
|
||||||
|
- Все игры: `rated_only=false`
|
||||||
|
|
||||||
|
### Возможные ошибки:
|
||||||
|
- **400**: Некорректные параметры (since >= until)
|
||||||
|
- **404**: Пользователь не найден
|
||||||
|
- **500**: Внутренняя ошибка сервера
|
||||||
|
"""
|
||||||
|
# Проверяем, что сервис статистики инициализирован
|
||||||
|
if not stats_service:
|
||||||
|
raise HTTPException(status_code=500, detail="Сервис не инициализирован")
|
||||||
|
|
||||||
|
# Валидация параметров времени
|
||||||
|
if since >= until:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Параметр 'since' должен быть меньше 'until'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Проверяем разумность периода (не более 1 года в миллисекундах)
|
||||||
|
if until - since > 365 * 24 * 3600 * 1000:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Период не может превышать 1 год"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Конвертируем миллисекунды в секунды для внутренней логики
|
||||||
|
since_seconds = since // 1000
|
||||||
|
until_seconds = until // 1000
|
||||||
|
result = await stats_service.get_games_of_period(username, since_seconds, until_seconds, rated_only)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка в endpoint get_games_of_period: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
|
||||||
|
|
||||||
|
@app.get("/puzzle/period",
|
||||||
|
response_model=PuzzleOfPeriodResponse,
|
||||||
|
tags=["statistics"],
|
||||||
|
summary="Статистика решения задач за период",
|
||||||
|
description="Получает статистику решения задач (пазлов) за указанный период времени. Требует авторизации через Bearer токен.",
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"description": "Статистика решения задач успешно получена",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"example": {
|
||||||
|
"message": "Статистика решения задач за период",
|
||||||
|
"period_start": 1640995200000,
|
||||||
|
"period_end": 1641081600000,
|
||||||
|
"max_puzzles": 50,
|
||||||
|
"puzzles_in_period": 15,
|
||||||
|
"data": {
|
||||||
|
"total_attempts": 15,
|
||||||
|
"solved": 12,
|
||||||
|
"failed": 3,
|
||||||
|
"success_rate": 80.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
"description": "Некорректные параметры запроса",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"example": {
|
||||||
|
"detail": "Параметр 'since' должен быть меньше 'until'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
"description": "Неверный токен авторизации",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"example": {
|
||||||
|
"message": "Неверный токен авторизации или доступ запрещен"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
"description": "Внутренняя ошибка сервера",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"example": {
|
||||||
|
"detail": "Внутренняя ошибка сервера: Connection timeout"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
async def get_puzzle_of_period(
|
||||||
|
since: int = Query(...,
|
||||||
|
description="Начало периода (Unix timestamp в миллисекундах)",
|
||||||
|
example=1640995200000),
|
||||||
|
until: int = Query(...,
|
||||||
|
description="Конец периода (Unix timestamp в миллисекундах)",
|
||||||
|
example=1641081600000),
|
||||||
|
max: int = Query(50,
|
||||||
|
description="Максимальное количество задач для получения от Lichess API. Внимание: если в периоде было больше задач, чем указано в max, будут показаны только последние N активностей",
|
||||||
|
example=50,
|
||||||
|
ge=1,
|
||||||
|
le=1000),
|
||||||
|
authorization: str = Header(...,
|
||||||
|
description="Bearer токен авторизации",
|
||||||
|
example="Bearer your_token_here")
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
## Статистика решения задач за период
|
||||||
|
|
||||||
|
Получает статистику решения задач (пазлов) за указанный период времени.
|
||||||
|
Требует авторизации через Bearer токен от Lichess.
|
||||||
|
|
||||||
|
### Возможности:
|
||||||
|
- **Фильтрация по времени**: точный период с Unix timestamp в миллисекундах
|
||||||
|
- **Статистика решений**: количество решенных и нерешенных задач
|
||||||
|
- **Процент успеха**: автоматический расчет процента успешных решений
|
||||||
|
- **Настраиваемый лимит**: максимальное количество задач для анализа
|
||||||
|
|
||||||
|
### Параметры:
|
||||||
|
- **since**: Начало периода в Unix timestamp (миллисекунды) (обязательно)
|
||||||
|
- **until**: Конец периода в Unix timestamp (миллисекунды) (обязательно)
|
||||||
|
- **max**: Максимальное количество задач (по умолчанию 50, максимум 1000)
|
||||||
|
- **Authorization**: Bearer токен в заголовке (обязательно)
|
||||||
|
|
||||||
|
### Примеры использования:
|
||||||
|
- Статистика за последние 7 дней: `since=1640995200000&until=1641081600000`
|
||||||
|
- Больше задач: `max=100`
|
||||||
|
- Заголовок: `Authorization: Bearer your_token_here`
|
||||||
|
|
||||||
|
### Получение токена:
|
||||||
|
1. Зайдите на https://lichess.org/account/oauth/token/create
|
||||||
|
2. Создайте новый токен с правами на чтение активности
|
||||||
|
3. Используйте токен в заголовке Authorization
|
||||||
|
|
||||||
|
### Возможные ошибки:
|
||||||
|
- **400**: Некорректные параметры (since >= until, неверный max)
|
||||||
|
- **401**: Неверный токен авторизации
|
||||||
|
- **500**: Внутренняя ошибка сервера
|
||||||
|
"""
|
||||||
|
# Проверяем, что сервис статистики инициализирован
|
||||||
|
if not stats_service:
|
||||||
|
raise HTTPException(status_code=500, detail="Сервис не инициализирован")
|
||||||
|
|
||||||
|
# Валидация параметров
|
||||||
|
if since >= until:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Параметр 'since' должен быть меньше 'until'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Проверяем разумность периода (не более 1 года в миллисекундах)
|
||||||
|
if until - since > 365 * 24 * 3600 * 1000:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Период не может превышать 1 год"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Извлекаем токен из заголовка Authorization
|
||||||
|
if not authorization.startswith("Bearer "):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Неверный формат токена. Используйте 'Bearer your_token'"
|
||||||
|
)
|
||||||
|
|
||||||
|
token = authorization[7:] # Убираем "Bearer " из начала
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await stats_service.get_puzzle_of_period(token, since, until, max)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка в endpoint get_puzzle_of_period: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||||
236
LichessWebServices/models.py
Normal file
236
LichessWebServices/models.py
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
"""
|
||||||
|
Lichess Statistics API - Модели данных
|
||||||
|
|
||||||
|
Этот модуль содержит все Pydantic модели для валидации и сериализации данных.
|
||||||
|
Модели используются для:
|
||||||
|
- Валидации входных параметров API
|
||||||
|
- Сериализации ответов API
|
||||||
|
- Документации в Swagger UI
|
||||||
|
- Типизации данных в коде
|
||||||
|
|
||||||
|
Автор: Lichess Web Services Team
|
||||||
|
Версия: 1.0.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Dict, Optional, Any
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# МОДЕЛИ СТАТИСТИКИ ЗАДАЧ (ПАЗЛОВ)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TaskStats(BaseModel):
|
||||||
|
"""
|
||||||
|
Статистика решения задач (пазлов) пользователя.
|
||||||
|
|
||||||
|
Содержит информацию о том, сколько задач пользователь решил,
|
||||||
|
сколько решил правильно и сколько не решил или решил неправильно.
|
||||||
|
"""
|
||||||
|
total: int = Field(..., description="Общее количество решенных задач", example=15)
|
||||||
|
solved: int = Field(..., description="Количество правильно решенных задач", example=12)
|
||||||
|
unsolved: int = Field(..., description="Количество нерешенных или неправильно решенных задач", example=3)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# МОДЕЛИ СТАТИСТИКИ ИГР
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class GameModeStats(BaseModel):
|
||||||
|
"""
|
||||||
|
Статистика игр для конкретного режима (Bullet, Blitz, Rapid и т.д.).
|
||||||
|
|
||||||
|
Содержит полную статистику по играм в определенном временном формате:
|
||||||
|
- Количество сыгранных игр
|
||||||
|
- Изменение рейтинга за период
|
||||||
|
- Текущий рейтинг
|
||||||
|
- Результаты игр (победы, поражения, ничьи)
|
||||||
|
"""
|
||||||
|
games_played: int = Field(..., description="Общее количество сыгранных игр", example=8)
|
||||||
|
rating_change: int = Field(..., description="Изменение рейтинга (может быть отрицательным)", example=15)
|
||||||
|
final_rating: int = Field(..., description="Текущий рейтинг игрока", example=2850)
|
||||||
|
wins: int = Field(..., description="Количество побед", example=5)
|
||||||
|
losses: int = Field(..., description="Количество поражений", example=2)
|
||||||
|
draws: int = Field(..., description="Количество ничьих", example=1)
|
||||||
|
|
||||||
|
class GamesStats(BaseModel):
|
||||||
|
"""
|
||||||
|
Статистика игр по всем режимам.
|
||||||
|
|
||||||
|
Агрегирует статистику игр по всем временным форматам:
|
||||||
|
- Bullet: быстрые игры (1-3 минуты)
|
||||||
|
- Blitz: блиц игры (3-10 минут)
|
||||||
|
- Rapid: рапид игры (10+ минут)
|
||||||
|
"""
|
||||||
|
bullet: GameModeStats = Field(..., description="Статистика Bullet игр (1-3 минуты)")
|
||||||
|
blitz: GameModeStats = Field(..., description="Статистика Blitz игр (3-10 минут)")
|
||||||
|
rapid: GameModeStats = Field(..., description="Статистика Rapid игр (10+ минут)")
|
||||||
|
|
||||||
|
class UserStats(BaseModel):
|
||||||
|
"""
|
||||||
|
Полная статистика пользователя.
|
||||||
|
|
||||||
|
Содержит всю доступную статистику по пользователю:
|
||||||
|
- Статистику решения задач (пазлов)
|
||||||
|
- Статистику игр по всем режимам
|
||||||
|
"""
|
||||||
|
username: str = Field(..., description="Имя пользователя на Lichess", example="magnus")
|
||||||
|
tasks: TaskStats = Field(..., description="Статистика решения задач")
|
||||||
|
games: GamesStats = Field(..., description="Статистика игр по всем режимам")
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# МОДЕЛИ ОТВЕТОВ API
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class ActivityResponse(BaseModel):
|
||||||
|
"""
|
||||||
|
Стандартный ответ API с результатами запроса статистики.
|
||||||
|
|
||||||
|
Используется для всех эндпоинтов статистики (сегодня, вчера, неделя).
|
||||||
|
Содержит сообщение о результате и данные статистики пользователя.
|
||||||
|
"""
|
||||||
|
message: str = Field(..., description="Сообщение о результате запроса", example="Статистика за сегодняшний день")
|
||||||
|
data: Optional[UserStats] = Field(None, description="Данные статистики пользователя (null если пользователь не найден или неактивен)")
|
||||||
|
|
||||||
|
class ErrorResponse(BaseModel):
|
||||||
|
"""
|
||||||
|
Модель для стандартизированных ошибок API.
|
||||||
|
|
||||||
|
Используется для возврата структурированных ошибок с дополнительной информацией.
|
||||||
|
"""
|
||||||
|
detail: str = Field(..., description="Описание ошибки", example="Пользователь не найден")
|
||||||
|
error_code: Optional[str] = Field(None, description="Код ошибки", example="USER_NOT_FOUND")
|
||||||
|
timestamp: Optional[str] = Field(None, description="Время возникновения ошибки", example="2024-01-15T10:30:00Z")
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
"""
|
||||||
|
Ответ для health check эндпоинта.
|
||||||
|
|
||||||
|
Используется для мониторинга состояния сервиса и проверки его работоспособности.
|
||||||
|
"""
|
||||||
|
status: str = Field(..., description="Статус сервиса", example="healthy")
|
||||||
|
timestamp: str = Field(..., description="Время проверки", example="2024-01-15T10:30:00Z")
|
||||||
|
service: str = Field(..., description="Название сервиса", example="Lichess Statistics API")
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# МОДЕЛИ ДЛЯ ЭНДПОИНТА СТАТИСТИКИ ИГР ЗА ПЕРИОД
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class GamePlayer(BaseModel):
|
||||||
|
"""
|
||||||
|
Информация об игроке в партии.
|
||||||
|
|
||||||
|
Содержит данные о пользователе, его рейтинге и изменении рейтинга в конкретной игре.
|
||||||
|
"""
|
||||||
|
user: Optional[Dict[str, Any]] = Field(None, description="Информация о пользователе")
|
||||||
|
rating: Optional[int] = Field(None, description="Рейтинг игрока")
|
||||||
|
ratingDiff: Optional[int] = Field(None, description="Изменение рейтинга")
|
||||||
|
|
||||||
|
class Game(BaseModel):
|
||||||
|
"""
|
||||||
|
Модель игры из Lichess API.
|
||||||
|
|
||||||
|
Содержит полную информацию об игре, включая:
|
||||||
|
- Метаданные игры (ID, время создания, статус)
|
||||||
|
- Информацию об игроках и их рейтингах
|
||||||
|
- Результат игры и ходы в PGN формате
|
||||||
|
"""
|
||||||
|
id: str = Field(..., description="ID игры")
|
||||||
|
rated: bool = Field(..., description="Рейтинговая ли игра")
|
||||||
|
variant: str = Field(..., description="Вариант игры")
|
||||||
|
speed: str = Field(..., description="Скорость игры (bullet, blitz, rapid, classical, correspondence)")
|
||||||
|
perf: str = Field(..., description="Тип производительности")
|
||||||
|
createdAt: int = Field(..., description="Время создания игры (timestamp)")
|
||||||
|
lastMoveAt: int = Field(..., description="Время последнего хода (timestamp)")
|
||||||
|
status: str = Field(..., description="Статус игры")
|
||||||
|
players: Dict[str, GamePlayer] = Field(..., description="Игроки (white, black)")
|
||||||
|
winner: Optional[str] = Field(None, description="Победитель (white, black или null)")
|
||||||
|
moves: str = Field(..., description="Ходы игры в PGN формате")
|
||||||
|
|
||||||
|
class GameStats(BaseModel):
|
||||||
|
"""
|
||||||
|
Статистика игр по конкретному типу (Bullet, Blitz, Rapid и т.д.).
|
||||||
|
|
||||||
|
Содержит агрегированную статистику по играм определенного типа:
|
||||||
|
- Количество сыгранных игр
|
||||||
|
- Результаты игр (победы, поражения, ничьи)
|
||||||
|
- Общее изменение рейтинга
|
||||||
|
- Итоговый рейтинг после последней игры
|
||||||
|
"""
|
||||||
|
games_played: int = Field(..., description="Общее количество сыгранных игр", example=10)
|
||||||
|
wins: int = Field(..., description="Количество побед", example=6)
|
||||||
|
losses: int = Field(..., description="Количество поражений", example=3)
|
||||||
|
draws: int = Field(..., description="Количество ничьих", example=1)
|
||||||
|
rating_change: int = Field(..., description="Общее изменение рейтинга", example=15)
|
||||||
|
rating: Optional[int] = Field(None, description="Итоговый рейтинг после последней игры (только если games_played > 0)", example=2850)
|
||||||
|
|
||||||
|
class GamesOfPeriodStats(BaseModel):
|
||||||
|
"""
|
||||||
|
Статистика игр за период по всем типам.
|
||||||
|
|
||||||
|
Агрегирует статистику игр по всем временным форматам:
|
||||||
|
- Bullet, Blitz, Rapid, Classical, Correspondence
|
||||||
|
- Общая статистика по всем типам
|
||||||
|
"""
|
||||||
|
bullet: GameStats = Field(..., description="Статистика Bullet игр")
|
||||||
|
blitz: GameStats = Field(..., description="Статистика Blitz игр")
|
||||||
|
rapid: GameStats = Field(..., description="Статистика Rapid игр")
|
||||||
|
classical: GameStats = Field(..., description="Статистика Classical игр")
|
||||||
|
correspondence: GameStats = Field(..., description="Статистика Correspondence игр")
|
||||||
|
total: GameStats = Field(..., description="Общая статистика по всем типам")
|
||||||
|
|
||||||
|
class GamesOfPeriodResponse(BaseModel):
|
||||||
|
"""
|
||||||
|
Ответ API с результатами запроса статистики игр за период.
|
||||||
|
|
||||||
|
Содержит метаинформацию о запросе и агрегированную статистику игр.
|
||||||
|
"""
|
||||||
|
message: str = Field(..., description="Сообщение о результате запроса", example="Статистика игр за период")
|
||||||
|
username: str = Field(..., description="Имя пользователя", example="magnus")
|
||||||
|
period_start: int = Field(..., description="Начало периода (Unix timestamp)", example=1640995200)
|
||||||
|
period_end: int = Field(..., description="Конец периода (Unix timestamp)", example=1641081600)
|
||||||
|
games_count: int = Field(..., description="Общее количество игр", example=25)
|
||||||
|
data: Optional[GamesOfPeriodStats] = Field(None, description="Данные статистики игр")
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# МОДЕЛИ ДЛЯ ЭНДПОИНТА СТАТИСТИКИ ЗАДАЧ ЗА ПЕРИОД
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class PuzzleActivity(BaseModel):
|
||||||
|
"""
|
||||||
|
Активность по решению задачи (пазла) из Lichess API.
|
||||||
|
|
||||||
|
Содержит информацию о попытке решения задачи пользователем:
|
||||||
|
- ID задачи и время решения
|
||||||
|
- Результат решения (решена/не решена)
|
||||||
|
- Дополнительная информация о задаче
|
||||||
|
"""
|
||||||
|
id: str = Field(..., description="ID задачи", example="abc123")
|
||||||
|
createdAt: int = Field(..., description="Время создания активности (timestamp в миллисекундах)", example=1640995200000)
|
||||||
|
win: bool = Field(..., description="Решена ли задача правильно", example=True)
|
||||||
|
puzzle: Dict[str, Any] = Field(..., description="Информация о задаче")
|
||||||
|
|
||||||
|
class PuzzleStats(BaseModel):
|
||||||
|
"""
|
||||||
|
Агрегированная статистика решения задач за период.
|
||||||
|
|
||||||
|
Содержит сводную информацию о решении задач:
|
||||||
|
- Общее количество попыток
|
||||||
|
- Количество успешных и неуспешных решений
|
||||||
|
- Процент успешности
|
||||||
|
"""
|
||||||
|
total_attempts: int = Field(..., description="Общее количество попыток решения", example=25)
|
||||||
|
solved: int = Field(..., description="Количество решенных задач", example=18)
|
||||||
|
failed: int = Field(..., description="Количество нерешенных задач", example=7)
|
||||||
|
success_rate: float = Field(..., description="Процент успешных решений", example=72.0)
|
||||||
|
|
||||||
|
class PuzzleOfPeriodResponse(BaseModel):
|
||||||
|
"""
|
||||||
|
Ответ API с результатами запроса статистики решения задач за период.
|
||||||
|
|
||||||
|
Содержит метаинформацию о запросе и агрегированную статистику решения задач.
|
||||||
|
"""
|
||||||
|
message: str = Field(..., description="Сообщение о результате запроса", example="Статистика решения задач за период")
|
||||||
|
period_start: int = Field(..., description="Начало периода (Unix timestamp в миллисекундах)", example=1640995200000)
|
||||||
|
period_end: int = Field(..., description="Конец периода (Unix timestamp в миллисекундах)", example=1641081600000)
|
||||||
|
max_puzzles: int = Field(..., description="Максимальное количество задач для получения", example=50)
|
||||||
|
puzzles_in_period: int = Field(..., description="Количество задач в указанном периоде", example=15)
|
||||||
|
data: Optional[PuzzleStats] = Field(None, description="Данные статистики решения задач")
|
||||||
20
LichessWebServices/requirements.txt
Normal file
20
LichessWebServices/requirements.txt
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Lichess Statistics API - Зависимости Python
|
||||||
|
#
|
||||||
|
# Этот файл содержит все необходимые Python пакеты для работы API.
|
||||||
|
# Установка: pip install -r requirements.txt
|
||||||
|
|
||||||
|
# FastAPI - современный веб-фреймворк для создания API
|
||||||
|
fastapi==0.104.1
|
||||||
|
|
||||||
|
# Uvicorn - ASGI сервер для запуска FastAPI приложения
|
||||||
|
# [standard] включает дополнительные зависимости для production
|
||||||
|
uvicorn[standard]==0.24.0
|
||||||
|
|
||||||
|
# HTTPX - асинхронный HTTP клиент для запросов к Lichess API
|
||||||
|
httpx==0.25.2
|
||||||
|
|
||||||
|
# Pydantic - библиотека для валидации данных и сериализации
|
||||||
|
pydantic==2.5.0
|
||||||
|
|
||||||
|
# Python-multipart - поддержка multipart/form-data для загрузки файлов
|
||||||
|
python-multipart==0.0.6
|
||||||
733
LichessWebServices/stats_service.py
Normal file
733
LichessWebServices/stats_service.py
Normal file
|
|
@ -0,0 +1,733 @@
|
||||||
|
"""
|
||||||
|
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"]:
|
||||||
|
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)
|
||||||
|
|
||||||
|
return GamesOfPeriodResponse(
|
||||||
|
message="Статистика игр за период",
|
||||||
|
username=username,
|
||||||
|
period_start=since_timestamp,
|
||||||
|
period_end=until_timestamp,
|
||||||
|
games_count=len(games),
|
||||||
|
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()
|
||||||
25
LichessWebView/.gitignore
vendored
Normal file
25
LichessWebView/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
13
LichessWebView/Dockerfile
Normal file
13
LichessWebView/Dockerfile
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
CMD ["python", "app.py"]
|
||||||
|
|
||||||
116
LichessWebView/app.py
Normal file
116
LichessWebView/app.py
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
from flask import Flask, jsonify, render_template
|
||||||
|
from flask_cors import CORS
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
CORS(app)
|
||||||
|
|
||||||
|
# Путь к базе данных бота
|
||||||
|
DB_PATH = "/app/data/lichess_bot.db"
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
"""Главная страница"""
|
||||||
|
return render_template('index.html')
|
||||||
|
|
||||||
|
@app.route('/api/users')
|
||||||
|
def get_users():
|
||||||
|
"""Получить всех пользователей"""
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(DB_PATH) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Получаем всех пользователей
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT
|
||||||
|
tu.user_id,
|
||||||
|
tu.username,
|
||||||
|
tu.first_name,
|
||||||
|
tu.last_name,
|
||||||
|
tu.created_at,
|
||||||
|
COUNT(ug.id) as gamer_count,
|
||||||
|
SUM(CASE WHEN ug.is_active = 1 THEN 1 ELSE 0 END) as active_gamers,
|
||||||
|
SUM(CASE WHEN ug.period_minutes > 0 THEN 1 ELSE 0 END) as monitored_gamers
|
||||||
|
FROM telegram_users tu
|
||||||
|
LEFT JOIN user_gamers ug ON tu.user_id = ug.user_id
|
||||||
|
GROUP BY tu.user_id, tu.username, tu.first_name, tu.last_name, tu.created_at
|
||||||
|
ORDER BY COALESCE(tu.first_name, tu.username, '')
|
||||||
|
''')
|
||||||
|
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
users = []
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
users.append({
|
||||||
|
'user_id': row[0],
|
||||||
|
'username': row[1] or '-',
|
||||||
|
'first_name': row[2] or '-',
|
||||||
|
'last_name': row[3],
|
||||||
|
'created_at': row[4],
|
||||||
|
'gamer_count': row[5],
|
||||||
|
'active_gamers': row[6],
|
||||||
|
'monitored_gamers': row[7]
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'users': users,
|
||||||
|
'total': len(users)
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
@app.route('/api/users/<int:user_id>/gamers')
|
||||||
|
def get_user_gamers(user_id):
|
||||||
|
"""Получить игроков конкретного пользователя"""
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(DB_PATH) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Получаем игроков пользователя
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT
|
||||||
|
g.id,
|
||||||
|
g.username,
|
||||||
|
g.token,
|
||||||
|
ug.is_active,
|
||||||
|
ug.period_minutes,
|
||||||
|
ug.created_at
|
||||||
|
FROM user_gamers ug
|
||||||
|
JOIN gamers g ON ug.gamer_id = g.id
|
||||||
|
WHERE ug.user_id = ?
|
||||||
|
ORDER BY g.username
|
||||||
|
''', (user_id,))
|
||||||
|
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
gamers = []
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
gamers.append({
|
||||||
|
'id': row[0],
|
||||||
|
'username': row[1],
|
||||||
|
'has_token': bool(row[2]),
|
||||||
|
'is_active': bool(row[3]),
|
||||||
|
'period_minutes': row[4],
|
||||||
|
'created_at': row[5]
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'gamers': gamers
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||||
|
|
||||||
12
LichessWebView/docker-compose.yml
Normal file
12
LichessWebView/docker-compose.yml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
services:
|
||||||
|
web-view:
|
||||||
|
build: .
|
||||||
|
container_name: lichess-web-view
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
volumes:
|
||||||
|
- ../LichessClientTG_bot/data:/app/data:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- FLASK_ENV=production
|
||||||
|
|
||||||
3
LichessWebView/requirements.txt
Normal file
3
LichessWebView/requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
Flask==3.0.0
|
||||||
|
flask-cors==4.0.0
|
||||||
|
|
||||||
440
LichessWebView/templates/index.html
Normal file
440
LichessWebView/templates/index.html
Normal file
|
|
@ -0,0 +1,440 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Lichess Bot Users Monitor</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 400px 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
height: calc(100vh - 40px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: white;
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gamers-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats strong {
|
||||||
|
color: #667eea;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-list {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-item {
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-item:hover {
|
||||||
|
background: #e9ecef;
|
||||||
|
transform: translateX(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-item.active {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
border-color: #5568d3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-item.active .user-stats {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-item.active .user-info {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-stats {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #667eea;
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-item.active .user-stats {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-stats span {
|
||||||
|
padding: 3px 8px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-item.active .user-stats span {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gamers-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gamers-table th {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
border-bottom: 2px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gamers-table td {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gamers-table tr:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-secondary {
|
||||||
|
background: #e2e3e5;
|
||||||
|
color: #383d41;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-token {
|
||||||
|
background: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-user-info {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-user-info h2 {
|
||||||
|
color: #333;
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-user-info p {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 5px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<!-- Левая панель: Пользователи -->
|
||||||
|
<div class="panel users-panel">
|
||||||
|
<h1>👥 Пользователи</h1>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
Всего пользователей: <strong id="total-users">0</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-box">
|
||||||
|
<input type="text" id="search-input" placeholder="Поиск по имени или никнейму...">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="users-list" id="users-list">
|
||||||
|
<div class="loading">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Правая панель: Игроки -->
|
||||||
|
<div class="panel gamers-panel">
|
||||||
|
<h1>🎮 Отслеживаемые игроки</h1>
|
||||||
|
|
||||||
|
<div id="selected-user-info" style="display: none;">
|
||||||
|
<div class="selected-user-info">
|
||||||
|
<h2 id="selected-user-name">Выберите пользователя</h2>
|
||||||
|
<p id="selected-user-username"></p>
|
||||||
|
<p id="selected-user-date"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="gamers-table-container">
|
||||||
|
<div class="empty">Выберите пользователя для просмотра его игроков</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let users = [];
|
||||||
|
let selectedUserId = null;
|
||||||
|
let filteredUsers = [];
|
||||||
|
|
||||||
|
// Загрузка пользователей
|
||||||
|
async function loadUsers() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/users');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
users = data.users;
|
||||||
|
document.getElementById('total-users').textContent = data.total;
|
||||||
|
filteredUsers = users;
|
||||||
|
renderUsers();
|
||||||
|
|
||||||
|
// Выбираем первого пользователя по умолчанию
|
||||||
|
if (users.length > 0) {
|
||||||
|
selectUser(users[0].user_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading users:', error);
|
||||||
|
document.getElementById('users-list').innerHTML = '<div class="empty">Ошибка загрузки данных</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рендеринг пользователей
|
||||||
|
function renderUsers() {
|
||||||
|
const usersList = document.getElementById('users-list');
|
||||||
|
|
||||||
|
if (filteredUsers.length === 0) {
|
||||||
|
usersList.innerHTML = '<div class="empty">Пользователи не найдены</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
usersList.innerHTML = filteredUsers.map(user => `
|
||||||
|
<div class="user-item ${selectedUserId === user.user_id ? 'active' : ''}" onclick="selectUser(${user.user_id})">
|
||||||
|
<div class="user-name">${escapeHtml(user.first_name)}</div>
|
||||||
|
<div class="user-info">@${escapeHtml(user.username)} • ID: ${user.user_id}</div>
|
||||||
|
<div class="user-info">Добавлен: ${formatDate(user.created_at)}</div>
|
||||||
|
<div class="user-stats">
|
||||||
|
<span>📊 ${user.gamer_count} игроков</span>
|
||||||
|
<span>✅ ${user.active_gamers} активен</span>
|
||||||
|
<span>⏰ ${user.monitored_gamers} с уведомл.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выбор пользователя
|
||||||
|
async function selectUser(userId) {
|
||||||
|
selectedUserId = userId;
|
||||||
|
renderUsers();
|
||||||
|
|
||||||
|
// Находим выбранного пользователя
|
||||||
|
const user = users.find(u => u.user_id === userId);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
document.getElementById('selected-user-info').style.display = 'block';
|
||||||
|
document.getElementById('selected-user-name').textContent = user.first_name;
|
||||||
|
document.getElementById('selected-user-username').textContent = `@${user.username} • ID: ${user.user_id}`;
|
||||||
|
document.getElementById('selected-user-date').textContent = `Добавлен: ${formatDate(user.created_at)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка игроков
|
||||||
|
await loadGamers(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка игроков пользователя
|
||||||
|
async function loadGamers(userId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/users/${userId}/gamers`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
renderGamers(data.gamers);
|
||||||
|
} else {
|
||||||
|
document.getElementById('gamers-table-container').innerHTML =
|
||||||
|
'<div class="empty">Ошибка загрузки игроков</div>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading gamers:', error);
|
||||||
|
document.getElementById('gamers-table-container').innerHTML =
|
||||||
|
'<div class="empty">Ошибка загрузки данных</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рендеринг игроков
|
||||||
|
function renderGamers(gamers) {
|
||||||
|
const container = document.getElementById('gamers-table-container');
|
||||||
|
|
||||||
|
if (gamers.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty">Нет отслеживаемых игроков</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = `
|
||||||
|
<table class="gamers-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Игрок</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Токен</th>
|
||||||
|
<th>Период уведомлений</th>
|
||||||
|
<th>Добавлен</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${gamers.map(gamer => `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${escapeHtml(gamer.username)}</strong> (ID: ${gamer.id})</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge ${gamer.is_active ? 'badge-success' : 'badge-secondary'}">
|
||||||
|
${gamer.is_active ? '✅ АКТИВЕН' : '⚪'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-token">
|
||||||
|
${gamer.has_token ? '🔑 Есть' : '❌ Нет'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
${gamer.period_minutes > 0 ?
|
||||||
|
`⏰ ${gamer.period_minutes} мин` :
|
||||||
|
'<span style="color: #999;">—</span>'}
|
||||||
|
</td>
|
||||||
|
<td style="color: #666;">${formatDate(gamer.created_at)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.innerHTML = table;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поиск
|
||||||
|
document.getElementById('search-input').addEventListener('input', function(e) {
|
||||||
|
const searchTerm = e.target.value.toLowerCase();
|
||||||
|
|
||||||
|
filteredUsers = users.filter(user => {
|
||||||
|
const name = (user.first_name || '').toLowerCase();
|
||||||
|
const username = (user.username || '').toLowerCase();
|
||||||
|
return name.includes(searchTerm) || username.includes(searchTerm);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderUsers();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Утилиты
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString) {
|
||||||
|
if (!dateString) return '—';
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('ru-RU') + ' ' + date.toLocaleTimeString('ru-RU', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация
|
||||||
|
loadUsers();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
240
README.md
Normal file
240
README.md
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
# Lichess Statistics Ecosystem
|
||||||
|
|
||||||
|
Полнофункциональная система для отслеживания статистики игроков Lichess.org с Telegram ботом и веб-интерфейсом.
|
||||||
|
|
||||||
|
## 🎯 Описание проекта
|
||||||
|
|
||||||
|
Система состоит из трех взаимосвязанных компонентов:
|
||||||
|
|
||||||
|
1. **LichessWebServices** - REST API для получения статистики игроков Lichess
|
||||||
|
2. **LichessClientTG_bot** - Telegram бот для управления подписками и уведомлений
|
||||||
|
3. **LichessWebView** - Веб-интерфейс для просмотра пользователей и их игроков
|
||||||
|
|
||||||
|
## 🏗️ Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Telegram Users │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ LichessClientTG_bot │ │
|
||||||
|
│ │ (Управление подписками, уведомления) │ │
|
||||||
|
│ └────────┬───────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ├──────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ ┌────────▼──────────┐ ┌──────▼──────────────┐ │
|
||||||
|
│ │ LichessWebServices │ │ LichessWebView │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ REST API для │ │ Веб-интерфейс │ │
|
||||||
|
│ │ получения │ │ для просмотра │ │
|
||||||
|
│ │ статистики │ │ пользователей │ │
|
||||||
|
│ └────────┬───────────┘ └────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
└───────────┼──────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────┐
|
||||||
|
│ Lichess API │
|
||||||
|
└───────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Быстрый старт
|
||||||
|
|
||||||
|
### Установка и запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Клонируем репозиторий
|
||||||
|
git clone https://github.com/vrubelroman/LichessStatTgWeb.git
|
||||||
|
cd LichessStatTgWeb
|
||||||
|
|
||||||
|
# Запускаем все сервисы
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Скрипт `start.sh` запустит все контейнеры в правильном порядке.
|
||||||
|
|
||||||
|
### Доступные сервисы
|
||||||
|
|
||||||
|
После запуска доступны:
|
||||||
|
|
||||||
|
- **API документация**: http://localhost:8001/docs
|
||||||
|
- **Веб-интерфейс**: http://localhost:5000
|
||||||
|
- **Telegram бот**: работает в фоне
|
||||||
|
|
||||||
|
## 📦 Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
LichessStatTgWeb/
|
||||||
|
├── LichessWebServices/ # REST API сервис
|
||||||
|
│ ├── main.py # FastAPI приложение
|
||||||
|
│ ├── stats_service.py # Логика обработки статистики
|
||||||
|
│ ├── lichess_client.py # Клиент для Lichess API
|
||||||
|
│ └── models.py # Pydantic модели
|
||||||
|
│
|
||||||
|
├── LichessClientTG_bot/ # Telegram бот
|
||||||
|
│ ├── bot.py # Основная логика бота
|
||||||
|
│ ├── database.py # Работа с БД
|
||||||
|
│ ├── lichess_api.py # API клиент
|
||||||
|
│ ├── formatters.py # Форматирование ответов
|
||||||
|
│ └── config.py # Конфигурация
|
||||||
|
│
|
||||||
|
├── LichessWebView/ # Веб-интерфейс
|
||||||
|
│ ├── app.py # Flask приложение
|
||||||
|
│ └── templates/ # HTML шаблоны
|
||||||
|
│ └── index.html # Главная страница
|
||||||
|
│
|
||||||
|
├── docker-compose.yml # Общая конфигурация контейнеров
|
||||||
|
├── start.sh # Скрипт запуска всех сервисов
|
||||||
|
└── README.md # Этот файл
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Компоненты
|
||||||
|
|
||||||
|
### 1. LichessWebServices (API)
|
||||||
|
|
||||||
|
REST API для получения статистики игроков Lichess.
|
||||||
|
|
||||||
|
**Возможности:**
|
||||||
|
- Статистика за сегодня/вчера/неделю
|
||||||
|
- Статистика игр по режимам (Bullet, Blitz, Rapid)
|
||||||
|
- Статистика решения задач (puzzles)
|
||||||
|
- Получение игр за произвольный период
|
||||||
|
|
||||||
|
**Endpoints:**
|
||||||
|
- `GET /stats/{username}/today` - статистика за сегодня
|
||||||
|
- `GET /stats/{username}/yesterday` - статистика за вчера
|
||||||
|
- `GET /stats/{username}/week` - статистика за неделю
|
||||||
|
- `GET /games/{username}/period` - игры за период
|
||||||
|
- `GET /puzzle/period` - задачи за период (требует токен)
|
||||||
|
|
||||||
|
### 2. LichessClientTG_bot (Telegram бот)
|
||||||
|
|
||||||
|
Telegram бот для управления отслеживанием игроков.
|
||||||
|
|
||||||
|
**Возможности:**
|
||||||
|
- Добавление игроков для отслеживания
|
||||||
|
- Выбор активного игрока
|
||||||
|
- Получение статистики (сегодня/вчера/неделя)
|
||||||
|
- Настройка периодических уведомлений
|
||||||
|
- Каждый пользователь имеет свой набор игроков
|
||||||
|
|
||||||
|
**Команды:**
|
||||||
|
- `/start` - начало работы с ботом
|
||||||
|
- `/adduser` - добавить игрока Lichess
|
||||||
|
- `/getgamers` - выбрать активного игрока
|
||||||
|
- `/today` - статистика за сегодня
|
||||||
|
- `/yesterday` - статистика за вчера
|
||||||
|
- `/week` - статистика за неделю
|
||||||
|
- `/setperiod` - настроить уведомления
|
||||||
|
|
||||||
|
### 3. LichessWebView (Веб-интерфейс)
|
||||||
|
|
||||||
|
Веб-интерфейс для просмотра пользователей и их игроков.
|
||||||
|
|
||||||
|
**Возможности:**
|
||||||
|
- Просмотр всех пользователей бота
|
||||||
|
- Фильтрация пользователей по имени/никнейму
|
||||||
|
- Отображение игроков каждого пользователя
|
||||||
|
- Статистика по каждому пользователю
|
||||||
|
- Отображение активных игроков и периодов
|
||||||
|
|
||||||
|
## 🗄️ База данных
|
||||||
|
|
||||||
|
Система использует SQLite базу данных с таблицами:
|
||||||
|
|
||||||
|
- `telegram_users` - пользователи Telegram
|
||||||
|
- `gamers` - игроки Lichess
|
||||||
|
- `user_gamers` - связь пользователей с игроками и настройки
|
||||||
|
|
||||||
|
**Структура:**
|
||||||
|
- Каждый пользователь видит только своих игроков
|
||||||
|
- У каждого пользователя свой активный игрок
|
||||||
|
- Период отслеживания привязывается к паре пользователь-игрок
|
||||||
|
|
||||||
|
## 🐳 Docker
|
||||||
|
|
||||||
|
Все компоненты запускаются в Docker контейнерах:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Запуск всех сервисов
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Просмотр логов
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Остановка всех сервисов
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔑 Конфигурация
|
||||||
|
|
||||||
|
### Telegram бот
|
||||||
|
|
||||||
|
Токен бота настраивается в `LichessClientTG_bot/config.py`:
|
||||||
|
```python
|
||||||
|
TELEGRAM_BOT_TOKEN = "YOUR_TOKEN_HERE"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lichess API
|
||||||
|
|
||||||
|
Для получения статистики по задачам нужен токен Lichess:
|
||||||
|
1. Зайдите на https://lichess.org/account/oauth/token/create
|
||||||
|
2. Создайте токен с правами на чтение
|
||||||
|
3. Используйте токен при добавлении игрока в боте
|
||||||
|
|
||||||
|
## 📊 API документация
|
||||||
|
|
||||||
|
Полная документация API доступна по адресу:
|
||||||
|
http://localhost:8001/docs
|
||||||
|
|
||||||
|
Включает:
|
||||||
|
- Swagger UI для интерактивного тестирования
|
||||||
|
- Описание всех endpoints
|
||||||
|
- Примеры запросов и ответов
|
||||||
|
|
||||||
|
## 🛠️ Разработка
|
||||||
|
|
||||||
|
### Локальная разработка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# API сервис
|
||||||
|
cd LichessWebServices
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Telegram бот
|
||||||
|
cd LichessClientTG_bot
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Веб-интерфейс
|
||||||
|
cd LichessWebView
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Логи
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Логи API
|
||||||
|
docker logs lichesswebservices_lichess-api_1 -f
|
||||||
|
|
||||||
|
# Логи бота
|
||||||
|
docker logs lichess-telegram-bot -f
|
||||||
|
|
||||||
|
# Логи веб-интерфейса
|
||||||
|
docker logs lichess-web-view -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Лицензия
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
## 👤 Автор
|
||||||
|
|
||||||
|
Roman Vrubel
|
||||||
|
|
||||||
|
## 🔗 Полезные ссылки
|
||||||
|
|
||||||
|
- [Lichess API Documentation](https://lichess.org/api)
|
||||||
|
- [Telegram Bot API](https://core.telegram.org/bots/api)
|
||||||
|
- [FastAPI Documentation](https://fastapi.tiangolo.com/)
|
||||||
|
|
||||||
54
docker-compose.yml
Normal file
54
docker-compose.yml
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
services:
|
||||||
|
# Lichess Web Services (API)
|
||||||
|
lichess-api:
|
||||||
|
build: ./LichessWebServices
|
||||||
|
container_name: lichess-api
|
||||||
|
ports:
|
||||||
|
- "8001:8000"
|
||||||
|
environment:
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
volumes:
|
||||||
|
- ./LichessWebServices:/app
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
# Telegram Bot
|
||||||
|
lichess-bot:
|
||||||
|
build: ./LichessClientTG_bot
|
||||||
|
container_name: lichess-telegram-bot
|
||||||
|
volumes:
|
||||||
|
- ./LichessClientTG_bot/data:/app/data
|
||||||
|
environment:
|
||||||
|
- PYTHONPATH=/app
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
network_mode: "host"
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- lichess-api
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:8001/health', timeout=5)"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
# Web View Interface
|
||||||
|
web-view:
|
||||||
|
build: ./LichessWebView
|
||||||
|
container_name: lichess-web-view
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
volumes:
|
||||||
|
- ./LichessClientTG_bot/data:/app/data:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- lichess-bot
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
name: lichess-network
|
||||||
|
|
||||||
50
start.sh
Executable file
50
start.sh
Executable file
|
|
@ -0,0 +1,50 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "🚀 Запуск Lichess Statistics Ecosystem..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Проверяем Docker
|
||||||
|
if ! command -v docker &> /dev/null; then
|
||||||
|
echo "❌ Docker не установлен. Установите Docker и попробуйте снова."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v docker-compose &> /dev/null; then
|
||||||
|
echo "❌ Docker Compose не установлен. Установите Docker Compose и попробуйте снова."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Останавливаем существующие контейнеры
|
||||||
|
echo "🛑 Остановка существующих контейнеров..."
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Пересобираем образы
|
||||||
|
echo "🔨 Пересборка Docker образов..."
|
||||||
|
docker-compose build
|
||||||
|
|
||||||
|
# Запускаем контейнеры
|
||||||
|
echo "🚀 Запуск контейнеров..."
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Ждем запуска
|
||||||
|
echo "⏳ Ожидание запуска сервисов..."
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# Проверяем статус
|
||||||
|
echo ""
|
||||||
|
echo "📊 Статус контейнеров:"
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Все сервисы запущены!"
|
||||||
|
echo ""
|
||||||
|
echo "🌐 Доступные сервисы:"
|
||||||
|
echo " - API документация: http://localhost:8001/docs"
|
||||||
|
echo " - Веб-интерфейс: http://localhost:5000"
|
||||||
|
echo ""
|
||||||
|
echo "📋 Для просмотра логов используйте:"
|
||||||
|
echo " docker-compose logs -f"
|
||||||
|
echo ""
|
||||||
|
echo "🛑 Для остановки используйте:"
|
||||||
|
echo " docker-compose down"
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue