Создание единого проекта 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
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>
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue