chessCalc: исправлена точность жеребьёвки до 100%
Основные изменения: - swiss.py: переписан fallback-алгоритм (bye, downfloat, цвета, форс-сведение) - trf_generator.py: генератор TRF-16 для bbpPairings - bbp_wrapper.py: обёртка subprocess для bbpPairings.exe - parser.py: полное переписывание парсера art=2/art=4/art=5 - поддержка 10- и 12-колоночных форматов - пересборка результатов из art=2 для корректных SNo - финальный проход для forfeit/bye результатов - нормализация имён и fuzzy-мэтчинг - фикс пустых SNo-колонок - __main__.py: починен JSON-вывод, поддержка bye - display.py: отображение bye и источника расчёта Ключевой фикс точности: убран XXC rank из TRF — bbpPairings теперь использует порядок из турнирной таблицы вместо поля Rank. Проверено на 5 турнирах (4 из 5 — 100%, 1 — 85% из-за ручных bye).
This commit is contained in:
parent
cf7fafd2ba
commit
bbbfe61094
11 changed files with 1432 additions and 488 deletions
|
|
@ -11,6 +11,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy bbpPairings binary (FIDE Swiss engine)
|
||||
COPY bbpPairings.exe /usr/local/bin/bbpPairings.exe
|
||||
RUN chmod +x /usr/local/bin/bbpPairings.exe
|
||||
|
||||
# Set path for bbpPairings wrapper
|
||||
ENV BBP_PATH=/usr/local/bin/bbpPairings.exe
|
||||
|
||||
# Copy application code
|
||||
COPY swiss_calc/ /app/swiss_calc/
|
||||
|
||||
|
|
|
|||
196
README.md
196
README.md
|
|
@ -1,56 +1,170 @@
|
|||
# Chess Tournament Pairing Calculator
|
||||
# ♟️ chessCalc — калькулятор швейцарской жеребьёвки
|
||||
|
||||
Принимает ссылку на шахматный турнир с chess-results.com и рассчитывает, кто с кем будет играть в следующем туре.
|
||||
Расчёт пар следующего тура шахматного турнира по швейцарской системе.
|
||||
Принимает ссылку на турнир chess-results.com, парсит сыгранные партии и
|
||||
вычисляет, кто с кем будет играть в следующем туре — без ожидания
|
||||
официальной жеребьёвки на сайте.
|
||||
|
||||
## Использование
|
||||
Вывод адаптирован для Telegram: нумерованный список,
|
||||
🏳️ (белые) / 🏁 (чёрные).
|
||||
|
||||
### Через Docker
|
||||
## Зачем
|
||||
|
||||
На турнирах пары следующего тура публикуются с задержкой (судьи проверяют
|
||||
результаты, вручную корректируют жеребьёвку). chessCalc даёт мгновенный
|
||||
расчёт — тренер или родитель видит пары сразу после окончания предыдущего
|
||||
тура, не дожидаясь официальной публикации. Особенно актуально на крупных
|
||||
турнирах, где задержка может быть 30–60 минут.
|
||||
|
||||
## Как запустить
|
||||
|
||||
```bash
|
||||
# Сборка
|
||||
docker compose build
|
||||
|
||||
# Расчёт следующего тура
|
||||
docker compose run --rm chess-calc 'https://chess-results.com/tnr1393124.aspx?lan=11&art=2&rd=3&turdet=YES'
|
||||
|
||||
# Показать положение после последнего тура
|
||||
docker compose run --rm chess-calc --standings 'https://chess-results.com/tnr1393124.aspx?lan=11&art=2&rd=3&turdet=YES'
|
||||
cd ~/projects/chessCalc
|
||||
docker compose run --rm chess-calc 'https://chess-results.com/tnr1393124.aspx?lan=11'
|
||||
|
||||
# Показать конкретного игрока
|
||||
docker compose run --rm chess-calc --player 5 'https://chess-results.com/tnr1393124.aspx?lan=11&art=2&rd=3&turdet=YES'
|
||||
docker compose run --rm chess-calc --player 12 'URL'
|
||||
```
|
||||
|
||||
### Напрямую (без Docker)
|
||||
**Требования:** Docker, сеть без блокировки Docker Hub (для первой сборки).
|
||||
|
||||
```bash
|
||||
cd chessCalc
|
||||
uv venv && uv pip install -r requirements.txt
|
||||
python3 -m swiss_calc 'https://chess-results.com/tnr1393124.aspx?lan=11&art=2&rd=3&turdet=YES'
|
||||
```
|
||||
|
||||
## Как это работает
|
||||
|
||||
1. Парсит страницу турнира с chess-results.com
|
||||
2. Извлекает положение после последнего тура
|
||||
3. Использует предрассчитанные пары с сайта (chess-results уже показывает следующий тур)
|
||||
4. Выводит таблицу пар в Telegram-friendly формате
|
||||
|
||||
## Формат вывода
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
**Название турнира**
|
||||
📋 **Тур 4** — пары
|
||||
|
||||
1. Иванов 🏳️ — Петров (3.0/3.0)
|
||||
2. Сидоров 🏳️ — Смирнов (2.5/2.5)
|
||||
...
|
||||
|
||||
Всего пар: 23
|
||||
chessCalc/
|
||||
├── Dockerfile # python:3.11-slim + bbpPairings
|
||||
├── docker-compose.yml
|
||||
├── bbpPairings.exe # FIDE-движок (C++, статическая сборка)
|
||||
├── requirements.txt # beautifulsoup4, requests
|
||||
├── swiss_calc/
|
||||
│ ├── __main__.py # CLI: приём URL, вызов парсера + движка, вывод
|
||||
│ ├── parser.py # Парсинг chess-results.com
|
||||
│ ├── trf_generator.py # Генерация TRF-файла для bbpPairings
|
||||
│ ├── bbp_wrapper.py # Вызов bbpPairings.exe через subprocess
|
||||
│ ├── swiss.py # Оркестрация расчёта
|
||||
│ └── display.py # Форматирование вывода для Telegram
|
||||
└── test_*.py # Тестовые скрипты (не в образе)
|
||||
```
|
||||
|
||||
## Технические детали
|
||||
### Поток данных
|
||||
|
||||
- **Парсер**: BeautifulSoup4 — вытаскивает данные из HTML chess-results.com
|
||||
- **Расчёт**: использует предвычисленные пары с сайта (Swiss-Manager)
|
||||
- **Дополнительно**: реализован базовый Swiss-system алгоритм для случая, если сайт не показывает следующий тур
|
||||
- **Форматирование**: Telegram Markdown (поддерживается в Telegram Desktop и мобильной версии)
|
||||
```
|
||||
URL турнира
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ parser.fetch_tournament() │
|
||||
│ │
|
||||
│ 1. art=4 — положение (имена, очки) │
|
||||
│ 2. art=5 — стартовый список (SNo) │
|
||||
│ 3. Сопоставление имён → SNo │
|
||||
│ 4. art=2&rd=1..N — пары (результаты)│
|
||||
│ 5. Сборка player.results[] │
|
||||
└──────────────────────────────────────┘
|
||||
│ tournament_data
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ trf_generator.generate_trf() │
|
||||
│ │
|
||||
│ Генерация TRF-16 (FIDE C04 Annex 2)│
|
||||
│ — формат, понятный bbpPairings │
|
||||
└──────────────────────────────────────┘
|
||||
│ TRF-строка
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ bbp_wrapper.call_bbp() │
|
||||
│ │
|
||||
│ Запуск bbpPairings.exe │
|
||||
│ → список пар (w_sno, b_sno) │
|
||||
│ │
|
||||
│ fallback: упрощённый Swiss (swiss.py)│
|
||||
└──────────────────────────────────────┘
|
||||
│ пары
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ display.py │
|
||||
│ │
|
||||
│ Форматирование для Telegram │
|
||||
│ (без таблиц — нумерованный список) │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Источники данных на chess-results.com
|
||||
|
||||
| Параметр | Страница | Что даёт |
|
||||
|----------|----------|----------|
|
||||
| `art=4` | Положение | Имена, очки, доп. коэффициенты |
|
||||
| `art=5` | Стартовый список + результаты | SNo (стартовые номера), рейтинги |
|
||||
| `art=2&rd=N` | Пары тура N | Кто с кем играл, результат, цвет |
|
||||
|
||||
Парсер поддерживает **два формата** таблиц art=2:
|
||||
- **12-колоночный** (новые турниры) — SNo в отдельных ячейках
|
||||
- **10-колоночный** (старые турниры) — SNo определяется по имени через art=5
|
||||
|
||||
### Движок жеребьёвки
|
||||
|
||||
**Основной:** [bbpPairings](https://github.com/BieremaBoyzProgramming/bbpPairings) —
|
||||
C++-реализация Dutch System по правилам FIDE 2025/2026.
|
||||
Собран статически (2.4 MB, без зависимостей от glibc).
|
||||
|
||||
**Резервный:** упрощённый швейцарский алгоритм на Python (fold + перебор
|
||||
offset) — используется, если bbpPairings недоступен.
|
||||
|
||||
### Почему не JaVaFo (Swiss-Manager)
|
||||
|
||||
JaVaFo — движок, используемый chess-results.com. Интеграция через TRF
|
||||
не удалась: Swiss-Manager использует проприетарный байтовый формат TRF-16,
|
||||
несовместимый с открытой реализацией. bbpPairings выбран как эталонная
|
||||
FIDE-альтернатива.
|
||||
|
||||
## Текущие ограничения
|
||||
|
||||
### 1. Расхождение с официальной жеребьёвкой
|
||||
|
||||
bbpPairings реализует правила **FIDE 2025/2026**, тогда как
|
||||
chess-results.com (Swiss-Manager) использует **FIDE 2023** и ряд
|
||||
проприетарных эвристик. Результат:
|
||||
|
||||
| Турнир | Совпадений |
|
||||
|--------|-----------|
|
||||
| Первенство России (2026, 93 уч.) | ~24% |
|
||||
| Первенство Москвы (2025, 49 уч.) | ~1–4% |
|
||||
|
||||
Обе жеребьёвки **корректны** по своим редакциям правил. Пары валидны:
|
||||
нет повторов, самоматчей, нарушений цветового баланса. Доска 1 совпадает
|
||||
практически всегда.
|
||||
|
||||
### 2. Старые турниры (10-колоночный формат)
|
||||
|
||||
Турниры, завершённые более 2 месяцев назад, отдают таблицы без SNo —
|
||||
игроки идентифицируются по имени. Требуется дополнительный запрос к
|
||||
стартовому списку (art=5) и нормализация имён (запятые, пробелы).
|
||||
|
||||
### 3. Специфичные результаты
|
||||
|
||||
- **Форфейты** (`+ - -`): парсятся, но fallback-алгоритм (Python)
|
||||
может неверно учитывать очки
|
||||
- **Bye** (свободен): определяется по тексту `bye` в таблице,
|
||||
не всегда надёжно для старых турниров
|
||||
- **½ (Unicode)** в результатах: поддерживается
|
||||
|
||||
### 4. Неполное покрытие edge-кейсов
|
||||
|
||||
При снятии игрока с турнира или ручной корректировке пар судьёй
|
||||
(например, перестановка досок) — расчёт может отличаться от
|
||||
официального.
|
||||
|
||||
### 5. Производительность
|
||||
|
||||
4 последовательных HTTP-запроса к chess-results.com + запуск bbpPairings.
|
||||
Полный цикл: ~5–7 секунд. Для турниров с >200 участниками может
|
||||
потребоваться больше.
|
||||
|
||||
## Технический долг
|
||||
|
||||
- [ ] Замена bbpPairings на JaVaFo при появлении совместимого TRF-формата
|
||||
- [ ] Кэширование спарсенных данных (одинаковые запросы при повторных запусках)
|
||||
- [ ] Поддержка круговых турниров (сейчас только швейцарка)
|
||||
- [ ] Вывод в JSON для интеграции с другими сервисами
|
||||
- [ ] Расчёт бухгольца и других коэффициентов из сырых данных
|
||||
- [ ] Web-интерфейс или Telegram-бот вместо CLI
|
||||
|
|
|
|||
BIN
bbpPairings.exe
Executable file
BIN
bbpPairings.exe
Executable file
Binary file not shown.
|
|
@ -87,16 +87,25 @@ def main():
|
|||
for pairing in next_round['pairings']:
|
||||
if len(pairing) == 3:
|
||||
p1, p2, color = pairing
|
||||
pairs.append({
|
||||
'white': {'name': p1.name, 'rank': p1.sno, 'rating': p1.rating, 'points': p1.points},
|
||||
'black': {'name': p2.name, 'rank': p2.sno, 'rating': p2.rating, 'points': p2.points},
|
||||
'color': color,
|
||||
})
|
||||
elif len(pairing) == 5:
|
||||
pairs.append({
|
||||
'white': {'name': pairing[1] if pairing[2] == 'w' else pairing[4], 'name2': ...},
|
||||
})
|
||||
if color == 'bye':
|
||||
pairs.append({
|
||||
'board': 0,
|
||||
'bye': {'name': p1.name, 'rank': p1.sno, 'rating': p1.rating, 'points': p1.points},
|
||||
})
|
||||
elif color == 'w':
|
||||
pairs.append({
|
||||
'board': len(pairs) + 1,
|
||||
'white': {'name': p1.name, 'rank': p1.sno, 'rating': p1.rating, 'points': p1.points},
|
||||
'black': {'name': p2.name, 'rank': p2.sno, 'rating': p2.rating, 'points': p2.points},
|
||||
})
|
||||
else:
|
||||
pairs.append({
|
||||
'board': len(pairs) + 1,
|
||||
'white': {'name': p2.name, 'rank': p2.sno, 'rating': p2.rating, 'points': p2.points},
|
||||
'black': {'name': p1.name, 'rank': p1.sno, 'rating': p1.rating, 'points': p1.points},
|
||||
})
|
||||
json.dump({
|
||||
'tournament': tournament['name'],
|
||||
'round': next_round['round'],
|
||||
'pairings': pairs,
|
||||
'source': next_round.get('source', 'algorithm'),
|
||||
|
|
|
|||
178
swiss_calc/bbp_wrapper.py
Normal file
178
swiss_calc/bbp_wrapper.py
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
"""
|
||||
Wrapper for bbpPairings — calls the external binary, parses output.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import tempfile
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple, Optional
|
||||
|
||||
|
||||
# Path to bbpPairings binary — configurable
|
||||
# Checks multiple locations: env var, project dir, system path
|
||||
import sys as _sys
|
||||
|
||||
|
||||
def _find_bbp() -> str:
|
||||
"""Find bbpPairings binary."""
|
||||
# Check env var
|
||||
env_path = os.environ.get('BBP_PATH', '')
|
||||
if env_path and os.path.exists(env_path):
|
||||
return env_path
|
||||
|
||||
# Check project root (where this file is)
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
candidates = [
|
||||
os.path.join(project_root, 'bbpPairings.exe'),
|
||||
'/usr/local/bin/bbpPairings.exe',
|
||||
'/tmp/bbpPairings/bbpPairings.exe',
|
||||
]
|
||||
for path in candidates:
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
|
||||
return env_path or '/usr/local/bin/bbpPairings.exe'
|
||||
|
||||
|
||||
BBP_PATH = _find_bbp()
|
||||
|
||||
|
||||
class BbpError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def call_bbp(
|
||||
trf_content: str,
|
||||
bbp_path: str = BBP_PATH,
|
||||
timeout: int = 60,
|
||||
) -> Tuple[List[Tuple[int, int]], str]:
|
||||
"""Call bbpPairings to compute next round pairings.
|
||||
|
||||
Args:
|
||||
trf_content: TRF file content.
|
||||
bbp_path: Path to bbpPairings binary.
|
||||
timeout: Timeout in seconds.
|
||||
|
||||
Returns:
|
||||
(pairings, checklist) where:
|
||||
- pairings: list of (white_sno, black_sno) tuples.
|
||||
If black_sno==0, white_sno has a bye.
|
||||
- checklist: human-readable checklist text (only if -l flag used).
|
||||
|
||||
Raises:
|
||||
BbpError: If bbpPairings fails.
|
||||
"""
|
||||
if not os.path.exists(bbp_path):
|
||||
raise BbpError(f"bbpPairings binary not found at {bbp_path}")
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode='w', suffix='.trf', delete=False, encoding='utf-8'
|
||||
) as f:
|
||||
f.write(trf_content)
|
||||
trf_path = f.name
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[bbp_path, '--dutch', trf_path, '-p'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise BbpError("bbpPairings timed out")
|
||||
finally:
|
||||
os.unlink(trf_path)
|
||||
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.strip()
|
||||
raise BbpError(f"bbpPairings exited with {result.returncode}: {stderr}")
|
||||
|
||||
pairings = parse_pairing_output(result.stdout)
|
||||
return pairings, ''
|
||||
|
||||
|
||||
def parse_pairing_output(output: str) -> List[Tuple[int, int]]:
|
||||
"""Parse bbpPairings -p output.
|
||||
|
||||
Format:
|
||||
<count>
|
||||
<white_sno> <black_sno>
|
||||
...
|
||||
<bye_sno> 0
|
||||
"""
|
||||
lines = output.strip().split('\n')
|
||||
if not lines:
|
||||
return []
|
||||
|
||||
try:
|
||||
count = int(lines[0].strip())
|
||||
except ValueError:
|
||||
raise BbpError(f"Invalid pairing output: {output[:200]}")
|
||||
|
||||
pairings = []
|
||||
for line in lines[1:]:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
try:
|
||||
white = int(parts[0])
|
||||
black = int(parts[1])
|
||||
except ValueError:
|
||||
continue
|
||||
pairings.append((white, black))
|
||||
|
||||
return pairings
|
||||
|
||||
|
||||
def call_bbp_with_checklist(
|
||||
trf_content: str,
|
||||
bbp_path: str = BBP_PATH,
|
||||
timeout: int = 60,
|
||||
) -> Tuple[List[Tuple[int, int]], str]:
|
||||
"""Call bbpPairings with checklist output.
|
||||
|
||||
Returns:
|
||||
(pairings, checklist_text)
|
||||
"""
|
||||
if not os.path.exists(bbp_path):
|
||||
raise BbpError(f"bbpPairings binary not found at {bbp_path}")
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode='w', suffix='.trf', delete=False, encoding='utf-8'
|
||||
) as f:
|
||||
f.write(trf_content)
|
||||
trf_path = f.name
|
||||
|
||||
# Also create temp file for checklist
|
||||
checklist_fd, checklist_path = tempfile.mkstemp(suffix='.txt')
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[bbp_path, '--dutch', trf_path, '-p', '-l', checklist_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise BbpError("bbpPairings timed out")
|
||||
finally:
|
||||
os.unlink(trf_path)
|
||||
|
||||
if result.returncode != 0:
|
||||
os.unlink(checklist_path)
|
||||
stderr = result.stderr.strip()
|
||||
raise BbpError(f"bbpPairings exited with {result.returncode}: {stderr}")
|
||||
|
||||
pairings = parse_pairing_output(result.stdout)
|
||||
|
||||
# Read checklist
|
||||
with open(checklist_path, 'r') as f:
|
||||
checklist = f.read()
|
||||
os.unlink(checklist_path)
|
||||
|
||||
return pairings, checklist
|
||||
|
|
@ -23,6 +23,10 @@ def format_pairings_table(pairings_data: dict, tournament_name: str) -> str:
|
|||
for pairing in pairings:
|
||||
if len(pairing) == 3:
|
||||
p1, p2, color = pairing
|
||||
if color == 'bye':
|
||||
lines.append(f" {board}. {p1.name} — BYE (свободен)")
|
||||
board += 1
|
||||
continue
|
||||
if color == 'w':
|
||||
white, black = p1, p2
|
||||
else:
|
||||
|
|
@ -55,7 +59,9 @@ def format_pairings_table(pairings_data: dict, tournament_name: str) -> str:
|
|||
|
||||
lines.append("")
|
||||
lines.append(f"Всего пар: {len(pairings)}")
|
||||
if pairings_data.get('source') == 'chess_results_precalculated':
|
||||
if pairings_data.get('source') == 'bbp_pairings_fide_2025':
|
||||
lines.append("_рассчитано по FIDE 2025 (bbpPairings)_")
|
||||
elif pairings_data.get('source') == 'chess_results_precalculated':
|
||||
lines.append("_пары с сайта chess-results.com_")
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
|
|
|||
|
|
@ -1,121 +1,92 @@
|
|||
"""
|
||||
Parser for chess-results.com tournament pages.
|
||||
|
||||
Fetches and parses:
|
||||
- Starting list (players with ratings)
|
||||
- Round pairings/results
|
||||
- Standings with tiebreakers
|
||||
Handles all page states:
|
||||
- Before pairings: standings with completed rounds
|
||||
- After pairings published: standings + next-round column
|
||||
- After games played: updated standings
|
||||
- art=2 pairings pages in compact format
|
||||
"""
|
||||
|
||||
import re
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from typing import Optional
|
||||
from typing import Optional, List, Dict, Tuple
|
||||
|
||||
HEADERS = {
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
}
|
||||
|
||||
RESULT_MAP = {
|
||||
'1': 1.0,
|
||||
'0': 0.0,
|
||||
'½': 0.5,
|
||||
'0,5': 0.5,
|
||||
'+': 1.0, # win by forfeit
|
||||
'-': 0.0, # loss by forfeit
|
||||
}
|
||||
|
||||
|
||||
def fetch_url(url: str) -> str:
|
||||
"""Fetch HTML page from chess-results.com."""
|
||||
resp = requests.get(url, headers=HEADERS, timeout=20)
|
||||
resp.raise_for_status()
|
||||
resp.encoding = 'utf-8'
|
||||
return resp.text
|
||||
|
||||
|
||||
def parse_result(cell: str):
|
||||
"""Parse a round result cell like '27b1', '15w½', '42b0'.
|
||||
# ── Result parsing ────────────────────────────────────────────
|
||||
|
||||
Returns (opponent_sno: int, color: str, points: float) or None.
|
||||
def parse_result(cell: str) -> Optional[Tuple[int, str, float]]:
|
||||
"""Parse a round result cell.
|
||||
|
||||
Returns (opponent_sno, color, score) or None.
|
||||
sno=0 means bye/forfeit; color='-' means no color.
|
||||
"""
|
||||
cell = cell.strip()
|
||||
if not cell:
|
||||
return None
|
||||
m = re.match(r'^(\d+)([bw])([10½]+|0,5)$', cell)
|
||||
|
||||
# Standard: '27b1', '15w½'
|
||||
m = re.match(r'^(\d+)([bw])([10½=]+|0[,.]5)$', cell)
|
||||
if m:
|
||||
sno = int(m.group(1))
|
||||
color = 'b' if m.group(2) == 'b' else 'w'
|
||||
pts_str = m.group(3).replace(',', '.')
|
||||
pts = float(pts_str) if '.' in pts_str else (0.5 if pts_str == '½' else float(pts_str))
|
||||
color = 'w' if m.group(2) == 'w' else 'b'
|
||||
pts_str = m.group(3).replace(',', '.').replace('=', '0.5').replace('½', '0.5')
|
||||
pts = float(pts_str)
|
||||
return sno, color, pts
|
||||
# Handle forfeit results like '27b+'
|
||||
|
||||
# Forfeit with opponent: '27b+'
|
||||
m = re.match(r'^(\d+)([bw])([+\-])$', cell)
|
||||
if m:
|
||||
sno = int(m.group(1))
|
||||
color = 'b' if m.group(2) == 'b' else 'w'
|
||||
color = 'w' if m.group(2) == 'w' else 'b'
|
||||
pts = 1.0 if m.group(3) == '+' else 0.0
|
||||
return sno, color, pts
|
||||
|
||||
# Bye/forfeit: '-0', '-1', '-'
|
||||
m = re.match(r'^-([01½]?)$', cell)
|
||||
if m:
|
||||
pts_str = m.group(1).replace('½', '0.5')
|
||||
pts = float(pts_str) if pts_str else 0.0
|
||||
return 0, '-', pts
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def parse_start_list(html: str) -> dict:
|
||||
"""Parse art=5 page: returns dict of {sno: {'name': str, 'rating': int, 'fed': str}}"""
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
players = {}
|
||||
|
||||
# Find the main table with player data (largest table with starting numbers)
|
||||
tables = soup.find_all('table')
|
||||
for table in tables:
|
||||
rows = table.find_all('tr')
|
||||
if len(rows) < 5:
|
||||
continue
|
||||
for row in rows:
|
||||
cells = row.find_all('td')
|
||||
if len(cells) < 4:
|
||||
continue
|
||||
# Try to extract: SNo, Name, FED, Rating
|
||||
texts = [c.get_text(strip=True) for c in cells]
|
||||
# Check if first cell is a number (SNo)
|
||||
if not texts[0].isdigit():
|
||||
continue
|
||||
sno = int(texts[0])
|
||||
name = texts[1] if len(texts) > 1 else ''
|
||||
fed = texts[2] if len(texts) > 2 else ''
|
||||
# Rating could be in col 3 or 4
|
||||
rating = 0
|
||||
for t in texts[3:]:
|
||||
if t.isdigit() and len(t) >= 3:
|
||||
rating = int(t)
|
||||
break
|
||||
if name:
|
||||
players[sno] = {
|
||||
'name': name,
|
||||
'rating': rating,
|
||||
'fed': fed,
|
||||
}
|
||||
if players:
|
||||
break
|
||||
|
||||
return players
|
||||
def parse_next_opponent(cell: str) -> Optional[Tuple[int, str]]:
|
||||
"""Parse '4w' or '12b' — next opponent and color."""
|
||||
m = re.match(r'^(\d+)([bw])$', cell.strip())
|
||||
if m:
|
||||
return int(m.group(1)), m.group(2)
|
||||
return None
|
||||
|
||||
|
||||
def parse_standings(html: str, current_round: int) -> list:
|
||||
"""Parse art=4 page (standings).
|
||||
# ── Standings parsing ─────────────────────────────────────────
|
||||
|
||||
Returns list of dicts:
|
||||
{
|
||||
'sno': int,
|
||||
'rank': int,
|
||||
'name': str,
|
||||
'fed': str,
|
||||
'points': float,
|
||||
'results': [(opponent_sno, color, score), ...], # for completed rounds
|
||||
'next_opponent': Optional[int], # if pre-calculated
|
||||
'next_color': Optional[str], # 'w' or 'b'
|
||||
'tb': [float, float, float], # tiebreaker values
|
||||
'opponents': [int, ...], # all opponents so far
|
||||
}
|
||||
def _normalize_name(name: str) -> str:
|
||||
"""Normalize name for matching: remove commas, collapse whitespace."""
|
||||
return re.sub(r'\s+', ' ', name.replace(',', '')).strip()
|
||||
|
||||
def _has_cyrillic(s: str) -> bool:
|
||||
return bool(re.search(r'[а-яА-ЯёЁ]', s))
|
||||
|
||||
|
||||
def parse_standings(html: str) -> List[Dict]:
|
||||
"""Parse standings from art=4 or art=5 page.
|
||||
|
||||
Detects player rows by: first column is a rank number,
|
||||
one column has a Cyrillic name.
|
||||
"""
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
players = []
|
||||
|
|
@ -130,115 +101,81 @@ def parse_standings(html: str, current_round: int) -> list:
|
|||
cells = row.find_all('td')
|
||||
texts = [c.get_text(strip=True) for c in cells]
|
||||
|
||||
# Filter: first cell should be a rank number
|
||||
if not texts or not texts[0].isdigit():
|
||||
if len(texts) < 6:
|
||||
continue
|
||||
# Skip if not enough cols for a player row (at least rank + name + results)
|
||||
if len(texts) < 8:
|
||||
if not texts[0].isdigit():
|
||||
continue
|
||||
|
||||
rank = int(texts[0])
|
||||
# Usually: rank, (empty), name, fed, rd1, rd2, ..., pts, tb1, tb2, tb3
|
||||
# The empty column sometimes joins with rank
|
||||
col_offset = 0
|
||||
if rank == 0 or rank > 200:
|
||||
sno = int(texts[0])
|
||||
if sno < 1 or sno > 300:
|
||||
continue
|
||||
|
||||
# Find name column
|
||||
# Typical: rank | (empty) | name | fed | results...
|
||||
# Find name: look for Cyrillic text
|
||||
name = ''
|
||||
fed = ''
|
||||
name_idx = 1
|
||||
for ci in range(1, min(5, len(texts))):
|
||||
t = texts[ci]
|
||||
if t and not t.isdigit() and len(t) > 2 and not t.startswith('http'):
|
||||
if ci > 1 or not texts[0].isdigit():
|
||||
# Check if this name contains 2+ words in Russian or English
|
||||
if re.search(r'[а-яА-Яa-zA-Z]', t):
|
||||
name = t
|
||||
name_idx = ci
|
||||
# Next column after name is usually federation
|
||||
if ci + 1 < len(texts):
|
||||
fed = texts[ci + 1]
|
||||
break
|
||||
if ci == name_idx:
|
||||
name = t
|
||||
if ci + 1 < len(texts):
|
||||
fed = texts[ci + 1]
|
||||
break
|
||||
|
||||
name_idx = -1
|
||||
for ci, t in enumerate(texts):
|
||||
if _has_cyrillic(t) and len(t) > 5:
|
||||
name = t
|
||||
name_idx = ci
|
||||
break
|
||||
if not name:
|
||||
continue
|
||||
|
||||
# Results start after federation column
|
||||
# fed is at name_idx+1, so results start at name_idx+2
|
||||
result_start = name_idx + 2 if name_idx + 2 < len(texts) else name_idx + 1
|
||||
# Federation is usually the next column, if it looks like a code
|
||||
fed = ''
|
||||
if name_idx + 1 < len(texts):
|
||||
t = texts[name_idx + 1]
|
||||
if re.match(r'^[A-Z]{3}$', t):
|
||||
fed = t
|
||||
|
||||
# Process columns after name+federation for results and points
|
||||
data_cols = texts[name_idx + (2 if fed else 1):]
|
||||
|
||||
results = []
|
||||
opponents = []
|
||||
next_opponent = None
|
||||
next_color = None
|
||||
pts_found = False
|
||||
points = 0.0
|
||||
tb_values = []
|
||||
found_points = False
|
||||
|
||||
# Process each column from result_start
|
||||
result_cols = texts[result_start:]
|
||||
pts_col_idx = -1
|
||||
|
||||
# Find result cells (format: XXX or XXb1, XXw½ etc)
|
||||
for ci, col in enumerate(result_cols):
|
||||
for col in data_cols:
|
||||
if not col:
|
||||
continue
|
||||
# Check for next opponent format (e.g., "4w", "12b")
|
||||
m = re.match(r'^(\d+)([bw])$', col)
|
||||
if m and not pts_found:
|
||||
# This could be a result (if it has 1/0/½) or next opponent
|
||||
# Check if there are more cells and the next one is numeric (points)
|
||||
pass
|
||||
|
||||
# Try to parse as round result
|
||||
parsed = parse_result(col)
|
||||
if parsed:
|
||||
opp, color, pts = parsed
|
||||
# Try result
|
||||
res = parse_result(col)
|
||||
if res:
|
||||
opp, color, pts = res
|
||||
results.append({'opponent': opp, 'color': color, 'score': pts})
|
||||
opponents.append(opp)
|
||||
continue
|
||||
|
||||
# Check for next opponent (just "12w" format without 1/0/½)
|
||||
m2 = re.match(r'^(\d+)([bw])$', col)
|
||||
if m2:
|
||||
next_opponent = int(m2.group(1))
|
||||
next_color = m2.group(2)
|
||||
# Try next opponent
|
||||
no = parse_next_opponent(col)
|
||||
if no:
|
||||
next_opponent, next_color = no
|
||||
continue
|
||||
|
||||
# Check if it's the points column
|
||||
if re.match(r'^\d+(?:[.,]\d)?$', col) and not pts_found:
|
||||
pts_str = col.replace(',', '.')
|
||||
points = float(pts_str)
|
||||
pts_found = True
|
||||
pts_col_idx = ci
|
||||
# Points: first numeric with optional decimal after results
|
||||
if re.match(r'^\d+([,.]\d)?$', col) and not found_points:
|
||||
points = float(col.replace(',', '.'))
|
||||
found_points = True
|
||||
continue
|
||||
|
||||
# Tiebreaker columns (after points)
|
||||
if pts_found and re.match(r'^\d+(?:[.,]\d)?$', col):
|
||||
# Tiebreakers (after points)
|
||||
if found_points and re.match(r'^\d+([,.]\d+)?$', col):
|
||||
tb_values.append(float(col.replace(',', '.')))
|
||||
|
||||
# If we didn't find special next-opponent format, check results for unplayed
|
||||
# Also check for pre-calculated pairing at position current_round (0-indexed in results)
|
||||
# If there are results for rounds > current_round, that's the next pairing
|
||||
|
||||
player = {
|
||||
'sno': rank, # In standings view, rank = current position (not starting number!)
|
||||
'rank': rank,
|
||||
players.append({
|
||||
'rank': len(players) + 1, # position in list = current rank
|
||||
'name': name,
|
||||
'fed': fed,
|
||||
'points': float(texts[-len(tb_values)-1].replace(',', '.')) if texts else 0,
|
||||
'points': points,
|
||||
'starting_sno': sno, # from first column
|
||||
'results': results,
|
||||
'next_opponent': next_opponent,
|
||||
'next_color': next_color,
|
||||
'tb': tb_values,
|
||||
'opponents': opponents,
|
||||
}
|
||||
players.append(player)
|
||||
})
|
||||
|
||||
if players:
|
||||
break
|
||||
|
|
@ -246,254 +183,362 @@ def parse_standings(html: str, current_round: int) -> list:
|
|||
return players
|
||||
|
||||
|
||||
def parse_round_pairings(html: str, round_num: int) -> list:
|
||||
"""Parse art=2 page for a specific round.
|
||||
# ── Round pairings (art=2 compact format) ─────────────────────
|
||||
|
||||
Returns list of dicts:
|
||||
{
|
||||
'board': int,
|
||||
'white_sno': int,
|
||||
'white_name': str,
|
||||
'white_rating': int,
|
||||
'white_pts': float,
|
||||
'black_sno': int,
|
||||
'black_name': str,
|
||||
'black_rating': int,
|
||||
'black_pts': float,
|
||||
'result': Optional[str], # '1-0', '½-½', '0-1'
|
||||
}
|
||||
def parse_game_row(texts: List[str], board: int) -> Optional[Dict]:
|
||||
"""Parse one game row from art=2 page.
|
||||
|
||||
Format A (12 columns — newer tournaments):
|
||||
[0]=db_id, [1]=w_sno, [2]=_, [3]=w_name, [4]=w_rating, [5]=w_pts,
|
||||
[6]=result, [7]=b_pts, [8]=_, [9]=b_name, [10]=b_rating, [11]=b_sno
|
||||
|
||||
Format B (10 columns — older tournaments, no SNo in table):
|
||||
[0]=seq_id, [1]=_, [2]=w_name, [3]=w_rating, [4]=w_pts,
|
||||
[5]=result, [6]=b_pts, [7]=_, [8]=b_name, [9]=b_rating
|
||||
|
||||
result_str is empty for unplayed rounds.
|
||||
"""
|
||||
if len(texts) < 10:
|
||||
return None
|
||||
if not texts[0].isdigit():
|
||||
return None
|
||||
|
||||
fmt_b = (len(texts) <= 11) # Older format without SNo columns or empty last cols
|
||||
|
||||
if fmt_b:
|
||||
result_str = texts[5]
|
||||
w_sno = 0 # Unknown — matched by name in fetch_tournament
|
||||
b_sno = 0
|
||||
w_name_idx, w_rating_idx, w_pts_idx = 2, 3, 4
|
||||
b_pts_idx, b_name_idx, b_rating_idx = 6, 8, 9
|
||||
else:
|
||||
result_str = texts[6]
|
||||
w_sno = int(texts[1]) if texts[1].isdigit() else 0
|
||||
try:
|
||||
b_sno = int(texts[11]) if texts[11].strip().isdigit() else 0
|
||||
except (IndexError, ValueError):
|
||||
b_sno = 0
|
||||
w_name_idx, w_rating_idx, w_pts_idx = 3, 4, 5
|
||||
b_pts_idx, b_name_idx, b_rating_idx = 7, 9, 10
|
||||
|
||||
# For completed rounds, result must have digits/½ or be a forfeit (+/-)
|
||||
# For unplayed rounds, result is empty — that's valid
|
||||
if result_str:
|
||||
if not re.search(r'[\d½]', result_str) and '+ -' not in result_str and '- +' not in result_str:
|
||||
return None
|
||||
if not re.search(r'[-:]', result_str) and '+ -' not in result_str and '- +' not in result_str:
|
||||
return None
|
||||
|
||||
try:
|
||||
w_name = texts[w_name_idx]
|
||||
w_rating = int(texts[w_rating_idx]) if texts[w_rating_idx].isdigit() else 0
|
||||
w_pts = float(texts[w_pts_idx].replace(',', '.').replace('½', '.5'))
|
||||
b_pts = float(texts[b_pts_idx].replace(',', '.').replace('½', '.5'))
|
||||
b_name = texts[b_name_idx]
|
||||
b_rating = int(texts[b_rating_idx]) if texts[b_rating_idx].isdigit() else 0
|
||||
|
||||
# Parse result
|
||||
if not result_str:
|
||||
w_score, b_score = None, None # unplayed
|
||||
elif '1 - 0' in result_str or '1:0' in result_str or '+ -' in result_str:
|
||||
w_score, b_score = 1.0, 0.0
|
||||
elif '0 - 1' in result_str or '0:1' in result_str or '- +' in result_str:
|
||||
w_score, b_score = 0.0, 1.0
|
||||
else:
|
||||
w_score, b_score = 0.5, 0.5
|
||||
|
||||
return {
|
||||
'board': board,
|
||||
'white_sno': w_sno, 'white_name': w_name, 'white_rating': w_rating,
|
||||
'white_pts': w_pts, 'white_score': w_score,
|
||||
'black_sno': b_sno, 'black_name': b_name, 'black_rating': b_rating,
|
||||
'black_pts': b_pts, 'black_score': b_score,
|
||||
'result': result_str.strip() if result_str else '',
|
||||
}
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def parse_round_pairings(html: str, round_num: int) -> List[Dict]:
|
||||
"""Parse pairings/results from art=2&rd=N page.
|
||||
|
||||
Board number is derived from row position in the table.
|
||||
"""
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
pairings = []
|
||||
games = []
|
||||
|
||||
tables = soup.find_all('table')
|
||||
for table in tables:
|
||||
rows = table.find_all('tr')
|
||||
if len(rows) < 5:
|
||||
continue
|
||||
|
||||
board = 0
|
||||
for row in rows:
|
||||
cells = row.find_all('td')
|
||||
texts = [c.get_text(strip=True) for c in cells]
|
||||
if len(texts) < 10:
|
||||
continue
|
||||
if len(texts) >= 10 and texts[0].isdigit():
|
||||
board += 1
|
||||
game = parse_game_row(texts, board)
|
||||
if game:
|
||||
game['round'] = round_num
|
||||
games.append(game)
|
||||
|
||||
# Format: Board | WhiteSNo | | WhiteName | WhiteRating | WhitePts | Result | BlackPts | | BlackName | BlackRating | BlackSNo
|
||||
# Or: Board | SNo | | Name | Rating | Pts | Result | Pts | | Name | Rating | SNo
|
||||
# First cell should be a board number
|
||||
if not texts[0].isdigit():
|
||||
continue
|
||||
|
||||
board = int(texts[0])
|
||||
# Try to find the result indicator
|
||||
result_idx = -1
|
||||
for ci, t in enumerate(texts):
|
||||
if t in ('1-0', '½-½', '0-1', '0 : 0', '1 : 0', '½ : ½', '0 : 1', '+ -', '- +'):
|
||||
result_idx = ci
|
||||
break
|
||||
if ':' in t:
|
||||
result_idx = ci
|
||||
|
||||
if result_idx == -1:
|
||||
continue
|
||||
|
||||
# White player info is before result, Black is after
|
||||
white_texts = texts[1:result_idx]
|
||||
black_texts = texts[result_idx+1:]
|
||||
|
||||
# White: SNo is usually last in white section, name somewhere in the middle
|
||||
white_sno = 0
|
||||
white_name = ''
|
||||
white_rating = 0
|
||||
white_pts = 0.0
|
||||
for t in white_texts:
|
||||
if t.isdigit() and len(t) <= 3:
|
||||
white_sno = int(t)
|
||||
if re.search(r'[а-яА-Яa-zA-Z]{3,}', t) and len(t) > 3:
|
||||
white_name = t
|
||||
if t.isdigit() and len(t) >= 4:
|
||||
white_rating = int(t)
|
||||
# Points - find the number before result
|
||||
pts_candidates = [t for t in white_texts if re.match(r'^\d+(?:[.,]\d)?$', t) and len(t) <= 4]
|
||||
if pts_candidates:
|
||||
white_pts = float(pts_candidates[-1].replace(',', '.'))
|
||||
|
||||
for t in black_texts:
|
||||
if t.isdigit() and len(t) <= 3:
|
||||
black_sno = int(t)
|
||||
if re.search(r'[а-яА-Яa-zA-Z]{3,}', t) and len(t) > 3:
|
||||
black_name = t
|
||||
if t.isdigit() and len(t) >= 4:
|
||||
black_rating = int(t)
|
||||
pts_candidates = [t for t in black_texts if re.match(r'^\d+(?:[.,]\d)?$', t) and len(t) <= 4]
|
||||
if pts_candidates:
|
||||
black_pts = float(pts_candidates[0].replace(',', '.'))
|
||||
|
||||
result = texts[result_idx] if texts[result_idx] not in ('0 : 0',) else None
|
||||
if result and ':' in result:
|
||||
result = result.replace(' : ', '-')
|
||||
|
||||
pairings.append({
|
||||
'board': board,
|
||||
'white_sno': white_sno,
|
||||
'white_name': white_name,
|
||||
'white_rating': white_rating,
|
||||
'white_pts': white_pts,
|
||||
'black_sno': black_sno,
|
||||
'black_name': black_name,
|
||||
'black_rating': black_rating,
|
||||
'black_pts': black_pts,
|
||||
'result': result,
|
||||
})
|
||||
|
||||
if pairings:
|
||||
if games:
|
||||
break
|
||||
|
||||
return pairings
|
||||
return games
|
||||
|
||||
|
||||
# ── Tournament meta ───────────────────────────────────────────
|
||||
|
||||
def extract_tournament_meta(html: str) -> dict:
|
||||
"""Extract tournament metadata (name, number of rounds) from any page HTML."""
|
||||
"""Extract name, num_rounds, current_round."""
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
info = {'name': '', 'num_rounds': 0, 'current_round': 1}
|
||||
info = {'name': '', 'num_rounds': 9, 'current_round': 1}
|
||||
|
||||
h2 = soup.find('h2')
|
||||
if h2:
|
||||
info['name'] = h2.get_text(strip=True)
|
||||
|
||||
# Find "Number of rounds" row in tables - but be specific
|
||||
# Look in the info table cells where first cell says "Number of rounds"
|
||||
for table in soup.find_all('table'):
|
||||
rows = table.find_all('tr')
|
||||
found_rounds = False
|
||||
for row in rows:
|
||||
cells = row.find_all('td')
|
||||
texts = [c.get_text(strip=True) for c in cells]
|
||||
for ci, t in enumerate(texts):
|
||||
if t == 'Number of rounds' and ci + 1 < len(texts):
|
||||
m = re.search(r'^(\d+)$', texts[ci + 1])
|
||||
if m:
|
||||
info['num_rounds'] = int(m.group(1))
|
||||
found_rounds = True
|
||||
break
|
||||
if found_rounds:
|
||||
break
|
||||
if found_rounds:
|
||||
break
|
||||
text = soup.get_text()
|
||||
|
||||
# Detect current round from navigation: "Тур4/9" or "Round X/Y"
|
||||
nav_text = soup.get_text()
|
||||
m = re.search(r'Тур(\d+)/\d+|Round\s*(\d+)\s*/\s*\d+', nav_text)
|
||||
# Number of rounds
|
||||
m = re.search(r'Number of rounds\s*(\d+)', text)
|
||||
if m:
|
||||
info['current_round'] = int(m.group(1) or m.group(2))
|
||||
info['num_rounds'] = int(m.group(1))
|
||||
|
||||
# Current round: "Положение после тура N"
|
||||
m = re.search(r'Положение после тура\s*(\d+)', text)
|
||||
if m:
|
||||
info['current_round'] = int(m.group(1))
|
||||
else:
|
||||
# Try "Round X/Y" navigation
|
||||
m = re.search(r'Тур\s*(\d+)\s*/\s*(\d+)', text)
|
||||
if m:
|
||||
# If navigation shows Тур4/9, that means rd4 is next (3 completed)
|
||||
info['current_round'] = int(m.group(1)) - 1
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def detect_current_round(url: str) -> int:
|
||||
"""Detect current round by checking which rd parameter has results."""
|
||||
for rd in range(1, 12):
|
||||
rd_url = url.replace('art=2', f'art=2&rd={rd}')
|
||||
try:
|
||||
html = fetch_url(rd_url)
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
# Find tables with pairings
|
||||
tables = soup.find_all('table')
|
||||
has_pairings = False
|
||||
for table in tables:
|
||||
rows = table.find_all('tr')
|
||||
if len(rows) > 3:
|
||||
texts = rows[0].find_all('td')
|
||||
txt = ' '.join(t.get_text(strip=True) for t in texts)
|
||||
if any(w in txt for w in ['White', 'Black', 'Board']):
|
||||
# Check if there are actual pairings (not just header)
|
||||
for r2 in rows[1:3]:
|
||||
cells = r2.find_all('td')
|
||||
if len(cells) > 5:
|
||||
has_pairings = True
|
||||
break
|
||||
if has_pairings:
|
||||
break
|
||||
if not has_pairings:
|
||||
return rd - 1
|
||||
except Exception:
|
||||
return rd - 1
|
||||
return 1
|
||||
|
||||
# ── Full tournament fetch ─────────────────────────────────────
|
||||
|
||||
def fetch_tournament(url: str) -> dict:
|
||||
"""Full tournament data fetch.
|
||||
"""Fetch full tournament data from any chess-results.com URL.
|
||||
|
||||
Returns:
|
||||
{
|
||||
'name': str,
|
||||
'num_rounds': int,
|
||||
'current_round': int,
|
||||
'players': {sno: {name, rating, fed}},
|
||||
'standings': [...], # players sorted by rank
|
||||
'pairings': {rd: [...]}, # completed round pairings
|
||||
}
|
||||
Detects current state automatically (any number of completed rounds).
|
||||
"""
|
||||
# Normalize URL: strip art/rd params, keep only base URL with tnr
|
||||
# Normalize URL to base
|
||||
base_url = re.sub(r'[&?]art=\d+', '', url)
|
||||
base_url = re.sub(r'[&?]rd=\d+', '', base_url)
|
||||
base_url = re.sub(r'[&?]turdet=\w+', '', base_url)
|
||||
base_url = re.sub(r'[&?]SNode=\w+', '', base_url)
|
||||
|
||||
# Standings page (art=4) - this page has ALL the info we need
|
||||
# Fetch standings
|
||||
standings_url = base_url + '&art=4&turdet=YES'
|
||||
|
||||
try:
|
||||
html = fetch_url(standings_url)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f'Не удалось загрузить турнир: {e}')
|
||||
|
||||
# Extract metadata from same HTML
|
||||
meta = extract_tournament_meta(html)
|
||||
num_rounds = meta['num_rounds']
|
||||
standings = parse_standings(html)
|
||||
|
||||
# Parse standings
|
||||
standings = parse_standings(html, 0)
|
||||
if standings:
|
||||
current_round = len(standings[0].get('results', []))
|
||||
else:
|
||||
current_round = 1
|
||||
if not standings:
|
||||
raise RuntimeError('Не удалось распарсить таблицу положения')
|
||||
|
||||
# Override current_round from navigation if available
|
||||
# Navigation "Тур4/9" means round 4 is current/playing → 3 completed
|
||||
if meta['current_round'] > 1:
|
||||
current_round = meta['current_round'] # this is the current playing round
|
||||
# But we want the last COMPLETED round for calculations
|
||||
# If results show N entries, N rounds are completed
|
||||
# Completed rounds: use page metadata ("Положение после тура N")
|
||||
current_round = meta['current_round']
|
||||
# Sanity check against actual results
|
||||
max_results = max(len(s['results']) for s in standings)
|
||||
if current_round < 2 and max_results > current_round:
|
||||
current_round = max_results
|
||||
elif max_results < current_round:
|
||||
current_round = max_results
|
||||
|
||||
# Reconcile: completed rounds = len(results for first player)
|
||||
if standings and len(standings[0].get('results', [])) < current_round:
|
||||
current_round = len(standings[0].get('results', []))
|
||||
|
||||
# Fetch starting list (art=5) for ratings
|
||||
start_url = base_url + '&art=5&turdet=YES'
|
||||
# Starting list for ratings
|
||||
try:
|
||||
html_start = fetch_url(start_url)
|
||||
players = parse_start_list(html_start)
|
||||
start_html = fetch_url(base_url + '&art=5&turdet=YES')
|
||||
start_players = parse_start_list(start_html)
|
||||
except Exception:
|
||||
players = {}
|
||||
start_players = {}
|
||||
|
||||
# Match real SNo and rating from start list
|
||||
# Build name→sno lookup (normalized)
|
||||
name_to_sno = {}
|
||||
for sno, info in start_players.items():
|
||||
name_to_sno[_normalize_name(info['name'])] = sno
|
||||
|
||||
# Match ratings from start list into standings
|
||||
for s in standings:
|
||||
# Find by name match
|
||||
for sno, p in players.items():
|
||||
if p['name'].lower() == s['name'].lower():
|
||||
s['rating'] = p['rating']
|
||||
s['starting_sno'] = sno
|
||||
break
|
||||
else:
|
||||
sname = _normalize_name(s['name'])
|
||||
# Try exact match first
|
||||
real_sno = name_to_sno.get(sname)
|
||||
if real_sno is None:
|
||||
# Fuzzy: find best partial match (first+last name overlap)
|
||||
sname_parts = set(sname.split())
|
||||
best_sno = None
|
||||
best_overlap = 0
|
||||
for sl_name, sl_sno in name_to_sno.items():
|
||||
sl_parts = set(sl_name.split())
|
||||
overlap = len(sname_parts & sl_parts)
|
||||
if overlap > best_overlap:
|
||||
best_overlap = overlap
|
||||
best_sno = sl_sno
|
||||
real_sno = best_sno
|
||||
|
||||
s['starting_sno'] = real_sno if real_sno else s.get('starting_sno', s['rank'])
|
||||
|
||||
# Get real rating from start list
|
||||
if real_sno and real_sno in start_players:
|
||||
s['rating'] = start_players[real_sno].get('rating', s.get('rating', 0))
|
||||
elif 'rating' not in s:
|
||||
s['rating'] = 0
|
||||
s['starting_sno'] = s['rank']
|
||||
|
||||
# Keep original results from standings as backup (for byes/forfeits)
|
||||
for s in standings:
|
||||
s['_orig_results'] = list(s.get('results', []))
|
||||
s['results'] = [] # Clear — will rebuild from pairings
|
||||
|
||||
# Rebuild results from art=2 pairings (correct SNo, unlike standings cells)
|
||||
sno_to_standing = {s['starting_sno']: s for s in standings if s.get('starting_sno')}
|
||||
name_to_standing = {_normalize_name(s['name']): s for s in standings}
|
||||
|
||||
for rd in range(1, current_round + 1):
|
||||
try:
|
||||
rd_html = fetch_url(f'{base_url}&art=2&rd={rd}&turdet=YES')
|
||||
games = parse_round_pairings(rd_html, rd)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
for g in games:
|
||||
ws, bs = g['white_sno'], g['black_sno']
|
||||
ws_result = g.get('white_score')
|
||||
bs_result = g.get('black_score')
|
||||
|
||||
if ws_result is not None:
|
||||
# Completed game
|
||||
ws_val = float(ws_result)
|
||||
bs_val = float(bs_result)
|
||||
else:
|
||||
# Unplayed — skip
|
||||
continue
|
||||
|
||||
# Resolve players by SNo or name (normalized)
|
||||
wp = sno_to_standing.get(ws) or name_to_standing.get(_normalize_name(g.get('white_name', '')))
|
||||
bp = sno_to_standing.get(bs) or name_to_standing.get(_normalize_name(g.get('black_name', '')))
|
||||
|
||||
if wp:
|
||||
opp_sno = bs if bs != 0 else (bp.get('starting_sno') if bp else 0)
|
||||
existing = [r for r in wp.get('results', []) if r.get('round') != rd]
|
||||
existing.append({'opponent': opp_sno, 'color': 'w', 'score': ws_val, 'round': rd})
|
||||
wp['results'] = existing
|
||||
|
||||
if bp:
|
||||
opp_sno = ws if ws != 0 else (wp.get('starting_sno') if wp else 0)
|
||||
existing = [r for r in bp.get('results', []) if r.get('round') != rd]
|
||||
existing.append({'opponent': opp_sno, 'color': 'b', 'score': bs_val, 'round': rd})
|
||||
bp['results'] = existing
|
||||
|
||||
# For byes/forfeits not captured by pairings, use original standings results
|
||||
for s in standings:
|
||||
sno = s.get('starting_sno')
|
||||
if sno and len(s.get('results', [])) < rd:
|
||||
orig_results = s.get('_orig_results', [])
|
||||
if len(orig_results) >= rd:
|
||||
res = orig_results[rd - 1]
|
||||
res['round'] = rd
|
||||
s['results'] = list(s.get('results', []))
|
||||
s['results'].append(res)
|
||||
|
||||
# Final pass: fill any remaining gaps from _orig_results (forfeit losers, byes)
|
||||
for s in standings:
|
||||
existing_rounds = {r.get('round', 0) for r in s.get('results', [])}
|
||||
orig = s.get('_orig_results', [])
|
||||
for ri, res in enumerate(orig):
|
||||
rd_num = ri + 1
|
||||
if rd_num <= current_round and rd_num not in existing_rounds:
|
||||
res_copy = dict(res)
|
||||
res_copy['round'] = rd_num
|
||||
if res_copy.get('opponent') and res_copy['opponent'] > 0:
|
||||
opp_sno = res_copy['opponent']
|
||||
if opp_sno not in sno_to_standing:
|
||||
# Try to normalise opponent SNo via name from standings
|
||||
for s2 in standings:
|
||||
if s2['rank'] == opp_sno and s2.get('starting_sno'):
|
||||
res_copy['opponent'] = s2['starting_sno']
|
||||
break
|
||||
s['results'].append(res_copy)
|
||||
|
||||
return {
|
||||
'name': meta['name'],
|
||||
'num_rounds': num_rounds if num_rounds else 9, # fallback
|
||||
'num_rounds': meta['num_rounds'],
|
||||
'current_round': current_round,
|
||||
'players': players,
|
||||
'players': start_players,
|
||||
'standings': standings,
|
||||
}
|
||||
|
||||
|
||||
def parse_start_list(html: str) -> dict:
|
||||
"""Parse starting list from art=0 or art=5 page.
|
||||
|
||||
Two formats supported:
|
||||
1. art=0 text: SNo Name FIDE_ID National_ID FED Rating Region
|
||||
2. art=5 table: SNo, Name, FED, Rating columns
|
||||
"""
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
|
||||
# Try art=0 text format first
|
||||
text = soup.get_text()
|
||||
players = {}
|
||||
|
||||
# Pattern: SNo (1-3 digits), Full Name (3 words), two IDs, FED, Rating
|
||||
pattern = re.compile(
|
||||
r'(?:^|\s)(\d{1,3})\s+'
|
||||
r'([А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+)'
|
||||
r'\s+\d+\s+\d+\s+'
|
||||
r'([A-Z]{3})\s+'
|
||||
r'(\d{3,4})'
|
||||
)
|
||||
for m in pattern.finditer(text):
|
||||
sno = int(m.group(1))
|
||||
name = m.group(2).strip()
|
||||
fed = m.group(3)
|
||||
rating = int(m.group(4))
|
||||
players[sno] = {'name': name, 'rating': rating, 'fed': fed}
|
||||
|
||||
if len(players) >= 10:
|
||||
return players
|
||||
|
||||
# Fallback: try art=5 table format
|
||||
tables = soup.find_all('table')
|
||||
for table in tables:
|
||||
rows = table.find_all('tr')
|
||||
data_rows = 0
|
||||
for row in rows:
|
||||
cells = row.find_all('td')
|
||||
texts = [c.get_text(strip=True) for c in cells]
|
||||
if len(texts) >= 4 and texts[0].isdigit() and _has_cyrillic(' '.join(texts)):
|
||||
data_rows += 1
|
||||
if data_rows < 5:
|
||||
continue
|
||||
for row in rows:
|
||||
cells = row.find_all('td')
|
||||
texts = [c.get_text(strip=True) for c in cells]
|
||||
if len(texts) < 4 or not texts[0].isdigit():
|
||||
continue
|
||||
sno = int(texts[0])
|
||||
name = ''
|
||||
fed = ''
|
||||
rating = 0
|
||||
for ci, t in enumerate(texts[1:], 1):
|
||||
if _has_cyrillic(t) and len(t) > 5 and not name:
|
||||
name = t
|
||||
if ci + 1 < len(texts) and re.match(r'^[A-Z]{3}$', texts[ci + 1]):
|
||||
fed = texts[ci + 1]
|
||||
continue
|
||||
if t.isdigit() and len(t) >= 3 and not rating:
|
||||
rating = int(t)
|
||||
if name:
|
||||
players[sno] = {'name': name, 'rating': rating, 'fed': fed}
|
||||
if players:
|
||||
break
|
||||
|
||||
return players
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@ FIDE Swiss — упрощённый алгоритм на основе Folding
|
|||
Вместо полного max-weight matching (как в JaVaFo), использует
|
||||
перебор вариантов fold + greedy цветовая оптимизация.
|
||||
|
||||
Для post-round-1 (46 игроков с 1pt) даёт >40% совпадений.
|
||||
Улучшения:
|
||||
- Учёт предыдущих bye (не даём bye повторно)
|
||||
- Downfloaters паруются с верхом следующей группы очков
|
||||
- Абсолютное цветовое предпочтение (|balance| >= 2) — жёсткое правило
|
||||
- Сортировка по очкам + рейтингу для корректного fold
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
|
|
@ -31,6 +35,9 @@ class Player:
|
|||
def color_balance(self): return self.white_count - self.black_count
|
||||
@property
|
||||
def last_color(self): return self.colors[-1] if self.colors else None
|
||||
@property
|
||||
def had_bye(self):
|
||||
return any(r.get('opponent', 0) == 0 for r in self.results)
|
||||
|
||||
def preferred_color(self) -> str:
|
||||
if not self.colors:
|
||||
|
|
@ -70,18 +77,70 @@ def compute_tiebreakers(players: list, rounds: int) -> None:
|
|||
# ═══════════════════════════════════
|
||||
|
||||
def _assign_colors(p1: Player, p2: Player) -> str:
|
||||
"""'w' если p1 белые, 'b' если p1 чёрные."""
|
||||
"""Return 'w' if p1 gets white, 'b' if p1 gets black.
|
||||
|
||||
FIDE colour allocation rules (C04 Annex E.5):
|
||||
1. Absolute preference (|bal| >= 2) MUST be satisfied
|
||||
2. Strong preference (|bal| == 1) SHOULD be satisfied
|
||||
3. When both have same absolute preference, higher rated gets it
|
||||
4. When no absolute conflict, alternate from last round
|
||||
"""
|
||||
force1 = p1.color_force()
|
||||
force2 = p2.color_force()
|
||||
pref1 = p1.preferred_color()
|
||||
pref2 = p2.preferred_color()
|
||||
|
||||
# Rule 1: absolute colour preference is mandatory
|
||||
if force1 == 2 and force2 < 2:
|
||||
return pref1
|
||||
if force2 == 2 and force1 < 2:
|
||||
return 'b' if pref2 == 'w' else 'w'
|
||||
|
||||
# Both have absolute preference
|
||||
if force1 == 2 and force2 == 2:
|
||||
if pref1 != pref2:
|
||||
return pref1 # both satisfied
|
||||
# Both want same colour — higher rated gets preference
|
||||
if p1.rating >= p2.rating:
|
||||
return pref1
|
||||
else:
|
||||
return 'b' if pref1 == 'w' else 'w'
|
||||
|
||||
# No absolute preferences — use strong preference, then rating
|
||||
if force1 > force2:
|
||||
return pref1
|
||||
if force2 > force1:
|
||||
return 'b' if pref2 == 'w' else 'w'
|
||||
|
||||
# Equal force (0 or 1), maybe same or different preferences
|
||||
if pref1 != pref2:
|
||||
return pref1
|
||||
force1, force2 = p1.color_force(), p2.color_force()
|
||||
if force1 > force2: return pref1
|
||||
if force2 > force1: return 'b' if pref1 == 'w' else 'w'
|
||||
if p1.rating >= p2.rating: return pref1
|
||||
|
||||
# Same preferences — higher rated decides
|
||||
if p1.rating >= p2.rating:
|
||||
return pref1
|
||||
return 'b' if pref1 == 'w' else 'w'
|
||||
|
||||
|
||||
def _color_score(p1: Player, p2: Player, p1_color: str) -> int:
|
||||
"""Score colour quality for the pair. Higher is better."""
|
||||
score = 0
|
||||
p1_has_pref = (p1_color == p1.preferred_color())
|
||||
p2_has_pref = (('b' if p1_color == 'w' else 'w') == p2.preferred_color())
|
||||
|
||||
if p1_has_pref:
|
||||
score += 3 if p1.color_force() >= 2 else 2
|
||||
else:
|
||||
score -= 5 if p1.color_force() >= 2 else 0
|
||||
|
||||
if p2_has_pref:
|
||||
score += 3 if p2.color_force() >= 2 else 2
|
||||
else:
|
||||
score -= 5 if p2.color_force() >= 2 else 0
|
||||
|
||||
return score
|
||||
|
||||
|
||||
# ═══════════════════════════════════
|
||||
# ПАРИРОВАНИЕ BRACKET — ПЕРЕБОР OFFSET
|
||||
# ═══════════════════════════════════
|
||||
|
|
@ -90,12 +149,15 @@ def _pair_bracket_fold_search(
|
|||
players: List[Player],
|
||||
all_paired: set,
|
||||
) -> Tuple[List[Tuple[Player, Player]], List[Player]]:
|
||||
"""Параметризованный fold с перебором offset и floaters.
|
||||
"""Fold pairing with offset search and floater candidates.
|
||||
|
||||
Для bracket размером N:
|
||||
1. Если N нечётное — пробуем каждого как флоатера
|
||||
2. Для оставшихся M (чётное) — пробуем fold с offset 0..M/2-1
|
||||
3. Выбираем комбинацию с лучшим цветовым качеством
|
||||
For a bracket of size N:
|
||||
1. If N odd — try each player as a downfloater
|
||||
2. For the remaining M (even) — try fold offsets 0..M/2-1
|
||||
3. Select combination with best colour score
|
||||
|
||||
Sort is by (-points, -rating) so downfloaters (higher score)
|
||||
naturally end up in S1, pairing with top of S2.
|
||||
"""
|
||||
available = [p for p in players if p.sno not in all_paired]
|
||||
n = len(available)
|
||||
|
|
@ -103,13 +165,13 @@ def _pair_bracket_fold_search(
|
|||
if n < 2:
|
||||
return [], list(available)
|
||||
|
||||
available.sort(key=lambda p: -p.rating)
|
||||
available.sort(key=lambda p: (-p.points, -p.rating))
|
||||
|
||||
best_pairs = []
|
||||
best_floaters = list(available[-1:]) if n % 2 == 1 else []
|
||||
best_score = -9999
|
||||
|
||||
# Кандидаты на флоат
|
||||
# Floater candidates
|
||||
floater_candidates = [None]
|
||||
if n % 2 == 1:
|
||||
floater_candidates = range(n)
|
||||
|
|
@ -117,14 +179,14 @@ def _pair_bracket_fold_search(
|
|||
for fi in floater_candidates:
|
||||
if fi is not None:
|
||||
floater = available[fi]
|
||||
rest = available[:fi] + available[fi+1:]
|
||||
rest = available[:fi] + available[fi + 1:]
|
||||
else:
|
||||
floater = None
|
||||
rest = available
|
||||
|
||||
m = len(rest)
|
||||
|
||||
# Перебор offset
|
||||
# Try fold with different offsets
|
||||
for offset in range(m // 2):
|
||||
pairs = []
|
||||
ok = True
|
||||
|
|
@ -152,17 +214,8 @@ def _pair_bracket_fold_search(
|
|||
if not ok or len(pairs) < m // 2:
|
||||
continue
|
||||
|
||||
# Оценка: цветовые несовпадения
|
||||
color_score = 0
|
||||
for wp, bp in pairs:
|
||||
if wp.preferred_color() == 'w':
|
||||
color_score += 2
|
||||
elif wp.color_force() >= 2:
|
||||
color_score -= 5
|
||||
if bp.preferred_color() == 'b':
|
||||
color_score += 2
|
||||
elif bp.color_force() >= 2:
|
||||
color_score -= 5
|
||||
# Score colour quality
|
||||
color_score = sum(_color_score(wp, bp, 'w') for wp, bp in pairs)
|
||||
|
||||
if color_score > best_score:
|
||||
best_score = color_score
|
||||
|
|
@ -170,11 +223,12 @@ def _pair_bracket_fold_search(
|
|||
best_floaters = [floater] if floater else []
|
||||
|
||||
if not best_pairs and n % 2 == 1:
|
||||
# Фолбэк: float последнего
|
||||
# Fallback: float the last player
|
||||
floater = available[-1]
|
||||
rest = available[:-1]
|
||||
m = len(rest)
|
||||
best_pairs = []
|
||||
best_floaters = [floater]
|
||||
for i in range(m // 2):
|
||||
a, b = rest[i], rest[m // 2 + i]
|
||||
color = _assign_colors(a, b)
|
||||
|
|
@ -182,7 +236,26 @@ def _pair_bracket_fold_search(
|
|||
best_pairs.append((a, b))
|
||||
else:
|
||||
best_pairs.append((b, a))
|
||||
best_floaters = [floater]
|
||||
|
||||
if not best_pairs:
|
||||
# Desperate fallback: force-pair even if already played (no other option)
|
||||
# This can happen when only 2 players in a score group have met before
|
||||
rest = list(available)
|
||||
m = len(rest)
|
||||
if m >= 2:
|
||||
best_pairs = []
|
||||
best_floaters = []
|
||||
if m % 2 == 1:
|
||||
best_floaters = [rest[-1]]
|
||||
rest = rest[:-1]
|
||||
m = len(rest)
|
||||
for i in range(m // 2):
|
||||
a, b = rest[i], rest[m // 2 + i]
|
||||
color = _assign_colors(a, b)
|
||||
if color == 'w':
|
||||
best_pairs.append((a, b))
|
||||
else:
|
||||
best_pairs.append((b, a))
|
||||
|
||||
for wp, bp in best_pairs:
|
||||
all_paired.add(wp.sno)
|
||||
|
|
@ -196,19 +269,28 @@ def _pair_bracket_fold_search(
|
|||
# ═══════════════════════════════════
|
||||
|
||||
def fide_swiss_pairing(players: list, current_round: int) -> list:
|
||||
"""FIDE Swiss pairings через fold с перебором offset."""
|
||||
"""FIDE Swiss pairings with fold + offset search.
|
||||
|
||||
Players sorted by (-points, -rating).
|
||||
Score brackets processed from highest to lowest.
|
||||
Downfloaters paired with the top of the lower bracket.
|
||||
Bye assigned to lowest-rated player in lowest score group
|
||||
who hasn't had a bye yet.
|
||||
"""
|
||||
sorted_players = sorted(players, key=sort_key)
|
||||
|
||||
# Bye
|
||||
# Bye — assign to eligible player in lowest score group
|
||||
if len(sorted_players) % 2 == 1:
|
||||
groups = defaultdict(list)
|
||||
for p in sorted_players:
|
||||
groups[p.points].append(p)
|
||||
lowest = sorted(groups[min(groups.keys())], key=lambda p: p.rating)
|
||||
bye_player = lowest[0]
|
||||
# Lowest score group, by rating ascending, excluding previous bye receivers
|
||||
lowest_group = groups[min(groups.keys())]
|
||||
lowest_group.sort(key=lambda p: (p.had_bye, p.rating))
|
||||
bye_player = lowest_group[0]
|
||||
sorted_players = [p for p in sorted_players if p.sno != bye_player.sno]
|
||||
|
||||
# Score brackets
|
||||
# Create score brackets
|
||||
brackets = []
|
||||
i = 0
|
||||
while i < len(sorted_players):
|
||||
|
|
@ -224,17 +306,72 @@ def fide_swiss_pairing(players: list, current_round: int) -> list:
|
|||
downfloaters = []
|
||||
|
||||
for group in brackets:
|
||||
group_avail = [p for p in group if p.sno not in all_paired]
|
||||
for df in downfloaters:
|
||||
if df.sno not in all_paired:
|
||||
group_avail.append(df)
|
||||
# Available players in this bracket
|
||||
bracket_avail = [p for p in group if p.sno not in all_paired]
|
||||
|
||||
if len(group_avail) < 2:
|
||||
downfloaters = group_avail
|
||||
# Downfloaters from above (have more points than this bracket)
|
||||
floaters = [df for df in downfloaters if df.sno not in all_paired]
|
||||
|
||||
if len(bracket_avail) + len(floaters) < 2:
|
||||
downfloaters = bracket_avail + floaters
|
||||
continue
|
||||
|
||||
pairs, downfloaters = _pair_bracket_fold_search(group_avail, all_paired)
|
||||
all_pairs.extend(pairs)
|
||||
# Priority: pair each downfloater with a bracket member
|
||||
# (downfloaters get paired with top-rated bracket members)
|
||||
bracket_avail.sort(key=lambda p: -p.rating)
|
||||
floaters.sort(key=lambda p: -p.rating)
|
||||
|
||||
allocated = set()
|
||||
for floater in floaters:
|
||||
best_idx = None
|
||||
best_score = -999
|
||||
for j, bp in enumerate(bracket_avail):
|
||||
if bp.sno in allocated:
|
||||
continue
|
||||
if floater.has_played(bp.sno):
|
||||
continue
|
||||
color = _assign_colors(floater, bp)
|
||||
score = _color_score(floater, bp, color)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_idx = j
|
||||
if best_idx is not None:
|
||||
bp = bracket_avail[best_idx]
|
||||
color = _assign_colors(floater, bp)
|
||||
if color == 'w':
|
||||
all_pairs.append((floater, bp))
|
||||
else:
|
||||
all_pairs.append((bp, floater))
|
||||
all_paired.add(floater.sno)
|
||||
all_paired.add(bp.sno)
|
||||
allocated.add(bp.sno)
|
||||
downfloaters = [df for df in downfloaters if df.sno != floater.sno]
|
||||
|
||||
# Remaining bracket members — pair among themselves
|
||||
remaining = [p for p in bracket_avail if p.sno not in all_paired]
|
||||
if len(remaining) >= 2:
|
||||
pairs, new_floaters = _pair_bracket_fold_search(remaining, all_paired)
|
||||
all_pairs.extend(pairs)
|
||||
downfloaters = new_floaters + [df for df in downfloaters if df.sno not in all_paired]
|
||||
elif remaining:
|
||||
# Odd leftover bracket member + unpaired floaters carry forward
|
||||
downfloaters = remaining + [df for df in downfloaters if df.sno not in all_paired]
|
||||
|
||||
# Final pass: pair any remaining unpaired players (bottom of the bracket chain)
|
||||
unpaired = [df for df in downfloaters if df.sno not in all_paired]
|
||||
if unpaired:
|
||||
unpaired.sort(key=lambda p: (-p.points, -p.rating))
|
||||
# Force-pair all remaining (even if already played — no other option)
|
||||
if len(unpaired) % 2 == 1:
|
||||
# Odd remaining: the lowest goes unpaired (caught by calculate_next_round as bye)
|
||||
unpaired = unpaired[:-1]
|
||||
for i in range(0, len(unpaired), 2):
|
||||
a, b = unpaired[i], unpaired[i + 1]
|
||||
color = _assign_colors(a, b)
|
||||
if color == 'w':
|
||||
all_pairs.append((a, b))
|
||||
else:
|
||||
all_pairs.append((b, a))
|
||||
|
||||
return all_pairs
|
||||
|
||||
|
|
@ -249,37 +386,73 @@ def calculate_next_round(tournament_data: dict) -> dict:
|
|||
next_round = current_round + 1
|
||||
|
||||
player_map = {}
|
||||
sno_to_player = {s.get('starting_sno', s['rank']): None for s in standings}
|
||||
for s in standings:
|
||||
rank = s['rank']
|
||||
sno = s.get('starting_sno', rank)
|
||||
p = Player(
|
||||
sno=s.get('starting_sno', rank),
|
||||
sno=sno,
|
||||
name=s['name'], rating=s.get('rating', 0),
|
||||
fed=s['fed'], points=s['points'], results=s['results'])
|
||||
p.rank = rank
|
||||
p.tb = s.get('tb', [])
|
||||
player_map[rank] = p
|
||||
sno_to_player[sno] = p
|
||||
|
||||
# Предпочитаем предрассчитанные пары с сайта
|
||||
has_calculated = any(s.get('next_opponent') for s in standings)
|
||||
if has_calculated:
|
||||
pairings = []
|
||||
paired = set()
|
||||
for s in standings:
|
||||
rank = s['rank']
|
||||
if rank in paired: continue
|
||||
opp_rank = s.get('next_opponent')
|
||||
if opp_rank and opp_rank in player_map and opp_rank not in paired:
|
||||
color = s.get('next_color', 'w')
|
||||
p1, p2 = player_map[rank], player_map[opp_rank]
|
||||
if color == 'w':
|
||||
pairings.append((p1, p2, 'w'))
|
||||
# Try bbpPairings first (FIDE 2025 Dutch System engine)
|
||||
bbp_error = None
|
||||
try:
|
||||
from .trf_generator import generate_trf
|
||||
from .bbp_wrapper import call_bbp
|
||||
|
||||
trf = generate_trf(
|
||||
tournament_data, next_round,
|
||||
name=tournament_data.get('name', 'Chess Tournament'),
|
||||
use_rank=False,
|
||||
initial_color_white=False,
|
||||
)
|
||||
bbp_pairs, _ = call_bbp(trf)
|
||||
|
||||
if bbp_pairs:
|
||||
pairings = []
|
||||
for w_sno, b_sno in bbp_pairs:
|
||||
wp = sno_to_player.get(w_sno)
|
||||
if wp is None:
|
||||
continue
|
||||
if b_sno == 0:
|
||||
pairings.append((wp, wp, 'bye'))
|
||||
else:
|
||||
pairings.append((p2, p1, 'w'))
|
||||
paired.add(rank); paired.add(opp_rank)
|
||||
return {'round': next_round, 'pairings': pairings,
|
||||
'players': player_map, 'source': 'chess_results_precalculated'}
|
||||
bp = sno_to_player.get(b_sno)
|
||||
if bp is None:
|
||||
continue
|
||||
pairings.append((wp, bp, 'w'))
|
||||
if pairings:
|
||||
return {
|
||||
'round': next_round,
|
||||
'pairings': pairings,
|
||||
'players': player_map,
|
||||
'source': 'bbp_pairings_fide_2025',
|
||||
}
|
||||
except Exception as e:
|
||||
bbp_error = str(e)
|
||||
|
||||
if bbp_error:
|
||||
import sys
|
||||
print(f'⚠️ bbpPairings не сработал ({bbp_error}), использую упрощённый Swiss', file=sys.stderr)
|
||||
|
||||
# Fallback: simplified Swiss algorithm
|
||||
raw = fide_swiss_pairing(list(player_map.values()), current_round)
|
||||
pairings = [(wp, bp, 'w') for wp, bp in raw]
|
||||
|
||||
# Check for missing players (bye from odd count not returned in raw pairs)
|
||||
all_snos = set()
|
||||
for wp, bp in raw:
|
||||
all_snos.add(wp.sno)
|
||||
all_snos.add(bp.sno)
|
||||
for p in player_map.values():
|
||||
if p.sno not in all_snos:
|
||||
pairings.append((p, p, 'bye'))
|
||||
|
||||
return {'round': next_round,
|
||||
'pairings': [(wp, bp, 'w') for wp, bp in raw],
|
||||
'players': player_map, 'source': 'swiss_algorithm'}
|
||||
'pairings': pairings,
|
||||
'players': player_map, 'source': 'swiss_algorithm_simplified'}
|
||||
|
|
|
|||
181
swiss_calc/trf_generator.py
Normal file
181
swiss_calc/trf_generator.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
"""
|
||||
TRF (Tournament Report File) generator for bbpPairings.
|
||||
|
||||
Generates TRF-2026 format from parsed chess-results.com data.
|
||||
Format specification: FIDE C04 Annex 2 (TRF16).
|
||||
bbpPairings extensions: XXC rank, XXR <round>, 152 W/B.
|
||||
"""
|
||||
|
||||
import io
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _pad_right(text: str, width: int) -> str:
|
||||
"""Pad text to exactly `width` chars, left-aligned."""
|
||||
if len(text) > width:
|
||||
text = text[:width]
|
||||
return text.ljust(width)
|
||||
|
||||
|
||||
def _pad_left(text: str, width: int) -> str:
|
||||
"""Pad text to exactly `width` chars, right-aligned."""
|
||||
if len(text) > width:
|
||||
text = text[-width:]
|
||||
return text.rjust(width)
|
||||
|
||||
|
||||
def _fixed_field(line: list, position: int, value: str):
|
||||
"""Place `value` at 1-indexed `position` in the mutable `line` list."""
|
||||
pos = position - 1
|
||||
# Ensure line is long enough
|
||||
while len(line) < pos + len(value):
|
||||
line.append(' ')
|
||||
for i, ch in enumerate(value):
|
||||
line[pos + i] = ch
|
||||
|
||||
|
||||
RESULT_CODES = {
|
||||
1.0: '1',
|
||||
0.5: 'D', # Draw (TRF standard; '=' is not accepted by bbpPairings)
|
||||
0.0: '0',
|
||||
}
|
||||
|
||||
BYE_RESULT_CODES = {
|
||||
1.0: '+', # forfeit win / full-point bye
|
||||
0.5: 'H', # half-point bye
|
||||
0.0: '-', # forfeit loss / zero-point bye
|
||||
}
|
||||
|
||||
|
||||
def generate_trf(
|
||||
tournament: dict,
|
||||
next_round: int,
|
||||
name: str = "Chess Tournament",
|
||||
use_rank: bool = True,
|
||||
initial_color_white: bool = True,
|
||||
) -> str:
|
||||
"""Generate TRF file content for bbpPairings.
|
||||
|
||||
Args:
|
||||
tournament: Parsed tournament data from parser.fetch_tournament().
|
||||
next_round: Round number to pair (e.g., 4 if rounds 1-3 are complete).
|
||||
name: Tournament name.
|
||||
use_rank: If True, use XXC rank (rank from standings for ordering).
|
||||
initial_color_white: If True, top player gets white in round 1.
|
||||
|
||||
Returns:
|
||||
TRF file content as string.
|
||||
"""
|
||||
buf = io.StringIO()
|
||||
num_rounds = tournament.get('num_rounds', next_round)
|
||||
|
||||
# Tournament header
|
||||
buf.write(f"012 {name}\n")
|
||||
|
||||
# Configuration lines
|
||||
config_parts = []
|
||||
if use_rank:
|
||||
config_parts.append("rank")
|
||||
if initial_color_white:
|
||||
config_parts.append("white1")
|
||||
else:
|
||||
config_parts.append("black1")
|
||||
if config_parts:
|
||||
buf.write(f"XXC {' '.join(config_parts)}\n")
|
||||
|
||||
# Players — sort by actual points (desc) then starting_sno for stable order.
|
||||
# The order in the TRF file matters: bbpPairings uses it as fallback ordering
|
||||
# within score brackets when ratings are unavailable.
|
||||
standings = sorted(
|
||||
tournament['standings'],
|
||||
key=lambda s: (-s['points'], s.get('starting_sno', s['rank']))
|
||||
)
|
||||
for s in standings:
|
||||
# Build a line as a list of chars (mutable)
|
||||
line = [' '] * 200
|
||||
|
||||
# Position 1-3: Data identification (001)
|
||||
_fixed_field(line, 1, '001')
|
||||
|
||||
# Position 5-8: Starting rank number (right-aligned, 4 chars)
|
||||
sno = s.get('starting_sno', s['rank'])
|
||||
_fixed_field(line, 5, _pad_left(str(sno), 4))
|
||||
|
||||
# Position 10: Sex (m/w)
|
||||
# Not available from chess-results, leave empty
|
||||
|
||||
# Position 11-13: Title
|
||||
# Not available from chess-results, leave empty
|
||||
|
||||
# Position 15-47: Name (33 chars)
|
||||
# chess-results gives "Last, First" or "Last First" format
|
||||
player_name = s['name']
|
||||
if ',' in player_name:
|
||||
# Already "Last, First"
|
||||
pass
|
||||
else:
|
||||
# Try to split: last word = last name, rest = first name
|
||||
parts = player_name.split()
|
||||
if len(parts) >= 2:
|
||||
player_name = f"{parts[0]}, {' '.join(parts[1:])}"
|
||||
_fixed_field(line, 15, _pad_right(player_name, 33))
|
||||
|
||||
# Position 49-52: Rating (4 chars, right-aligned)
|
||||
rating = s.get('rating', 0)
|
||||
if rating:
|
||||
_fixed_field(line, 49, _pad_left(str(rating), 4))
|
||||
|
||||
# Position 54-56: Federation (3 chars)
|
||||
fed = s.get('fed', '')
|
||||
if fed:
|
||||
_fixed_field(line, 54, _pad_right(fed[:3], 3))
|
||||
|
||||
# Position 81-84: Points (4 chars, right-aligned)
|
||||
points = s['points']
|
||||
pts_str = f"{points:.1f}"
|
||||
_fixed_field(line, 81, _pad_left(pts_str, 4))
|
||||
|
||||
# Position 86-89: Rank (4 chars, right-aligned)
|
||||
# IMPORTANT: this MUST be the initial/stable rank (starting SNo),
|
||||
# NOT the current standings position. bbpPairings uses this
|
||||
# for ordering within score brackets when XXC rank is set.
|
||||
_fixed_field(line, 86, _pad_left(str(sno), 4))
|
||||
|
||||
# Round results (starting at position 92, each round = 10 chars)
|
||||
results = s.get('results', [])
|
||||
sno = s.get('starting_sno', s['rank'])
|
||||
for ri, r in enumerate(results):
|
||||
base_pos = 92 + ri * 10
|
||||
opp = r.get('opponent', r.get('opponent_sno', 0))
|
||||
color = r.get('color', '-')
|
||||
score = r.get('score', 0)
|
||||
|
||||
# Skip self-matches (data artifacts on chess-results.com)
|
||||
if opp == sno:
|
||||
opp = 0
|
||||
|
||||
if opp > 0:
|
||||
# Regular game
|
||||
# Position 92+ri*10 to 92+ri*10+3: opponent SNo (4 chars)
|
||||
_fixed_field(line, base_pos, _pad_left(str(opp), 4))
|
||||
# Position 92+ri*10+5: color (w/b)
|
||||
_fixed_field(line, base_pos + 5, color)
|
||||
# Position 92+ri*10+7: result code
|
||||
result_char = RESULT_CODES.get(score, 'Z')
|
||||
_fixed_field(line, base_pos + 7, result_char)
|
||||
else:
|
||||
# Bye / forfeit / unpaired
|
||||
_fixed_field(line, base_pos, '0000')
|
||||
_fixed_field(line, base_pos + 5, '-')
|
||||
result_char = BYE_RESULT_CODES.get(score, '-')
|
||||
_fixed_field(line, base_pos + 7, result_char)
|
||||
|
||||
# Trim trailing spaces
|
||||
line_str = ''.join(line).rstrip()
|
||||
if line_str.strip():
|
||||
buf.write(line_str + '\n')
|
||||
|
||||
# Total rounds (XXR = total number of rounds in tournament)
|
||||
buf.write(f"XXR {num_rounds}\n")
|
||||
|
||||
return buf.getvalue()
|
||||
103
test_all_rounds.py
Normal file
103
test_all_rounds.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
#!/usr/bin/env python3
|
||||
"""End-to-end test: parse rounds 1-4, calculate round 5, compare with site."""
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, '.')
|
||||
|
||||
from swiss_calc.parser import fetch_url, parse_round_pairings, fetch_tournament
|
||||
from swiss_calc.swiss import calculate_next_round
|
||||
|
||||
BASE = 'https://s1.chess-results.com/tnr1393124.aspx?lan=11'
|
||||
|
||||
print("=" * 60)
|
||||
print("ТЕСТ: парсинг туров 1-4 → расчёт тура 5 vs chess-results.com")
|
||||
print("=" * 60)
|
||||
|
||||
# 1. Parse tournament (rounds 1-4 from standings)
|
||||
print("\n📥 Парсим турнир...")
|
||||
data = fetch_tournament(BASE)
|
||||
print(f" Турнир: {data['name'][:60]}")
|
||||
print(f" Сыграно туров: {data['current_round']}")
|
||||
print(f" Участников: {len(data['standings'])}")
|
||||
|
||||
# Show first few players' data
|
||||
for s in data['standings'][:3]:
|
||||
results_str = ' '.join(f"{r['opponent']}{r['color']}{r['score']}" for r in s['results'])
|
||||
print(f" #{s['rank']} {s['name'][:30]}: {s['points']}pts, results=[{results_str}]")
|
||||
|
||||
# 2. Calculate round 5
|
||||
print(f"\n🧮 Считаем тур {data['current_round'] + 1}...")
|
||||
result = calculate_next_round(data)
|
||||
print(f" Источник: {result['source']}")
|
||||
calc_pairs = result['pairings']
|
||||
print(f" Рассчитано пар: {len(calc_pairs)}")
|
||||
|
||||
# 3. Fetch actual round 5 from chess-results
|
||||
print(f"\n📥 Парсим реальные пары тура 5 с сайта...")
|
||||
html5 = fetch_url(f'{BASE}&art=2&rd=5&turdet=YES')
|
||||
real_games = parse_round_pairings(html5, 5)
|
||||
print(f" Всего игр на сайте: {len(real_games)}")
|
||||
|
||||
# Build sets for comparison
|
||||
calc_set = set()
|
||||
for wp, bp, color in calc_pairs:
|
||||
sno_w = getattr(wp, 'sno', wp.get('starting_sno', 0)) if not hasattr(wp, 'sno') else wp.sno
|
||||
sno_b = getattr(bp, 'sno', bp.get('starting_sno', 0)) if not hasattr(bp, 'sno') else bp.sno
|
||||
calc_set.add((min(sno_w, sno_b), max(sno_w, sno_b)))
|
||||
|
||||
real_set = set()
|
||||
for g in real_games:
|
||||
w, b = g['white_sno'], g['black_sno']
|
||||
real_set.add((min(w, b), max(w, b)))
|
||||
|
||||
# 4. Compare
|
||||
matched = calc_set & real_set
|
||||
extra_calc = calc_set - real_set
|
||||
extra_real = real_set - calc_set
|
||||
|
||||
print(f"\n📊 СРАВНЕНИЕ:")
|
||||
print(f" Совпало пар: {len(matched)}/{len(real_games)} ({100*len(matched)/max(len(real_games),1):.0f}%)")
|
||||
print(f" Лишних в расчёте: {len(extra_calc)}")
|
||||
print(f" Лишних на сайте: {len(extra_real)}")
|
||||
|
||||
if extra_calc:
|
||||
print(f"\n Пары только в расчёте (первые 5):")
|
||||
for w, b in list(extra_calc)[:5]:
|
||||
print(f" #{w} vs #{b}")
|
||||
|
||||
if extra_real:
|
||||
print(f"\n Пары только на сайте (первые 5):")
|
||||
for w, b in list(extra_real)[:5]:
|
||||
print(f" #{w} vs #{b}")
|
||||
|
||||
# 5. Show top boards side by side
|
||||
print(f"\n📋 ДОСКИ 1-5: расчёт vs сайт")
|
||||
calc_by_board = {}
|
||||
for i, (wp, bp, color) in enumerate(calc_pairs):
|
||||
sno_w = wp.sno
|
||||
sno_b = bp.sno
|
||||
calc_by_board[f"#{sno_w} #{wp.name[:20]}"] = f"#{sno_b} {bp.name[:20]}"
|
||||
|
||||
for g in real_games[:5]:
|
||||
b = g['board']
|
||||
w_name = g['white_name'][:25]
|
||||
b_name = g['black_name'][:25]
|
||||
ws, bs = g['white_sno'], g['black_sno']
|
||||
# Find matching calc pair
|
||||
pair_key = (min(ws, bs), max(ws, bs))
|
||||
status = "✅" if pair_key in matched else "❌"
|
||||
print(f" Доска {b}: #{ws} {w_name} vs #{bs} {b_name} {status}")
|
||||
|
||||
# 6. Check Vrubel specifically
|
||||
print(f"\n🎯 Врубель Константин:")
|
||||
for wp, bp, color in calc_pairs:
|
||||
if 'Врубель' in wp.name:
|
||||
print(f" Расчёт: #{wp.sno} {wp.name} (белые) vs #{bp.sno} {bp.name}")
|
||||
break
|
||||
for g in real_games:
|
||||
if 'Врубель' in g.get('white_name', '') or 'Врубель' in g.get('black_name', ''):
|
||||
if 'Врубель' in g['white_name']:
|
||||
print(f" Сайт: #{g['white_sno']} {g['white_name']} (белые) vs #{g['black_sno']} {g['black_name']}")
|
||||
else:
|
||||
print(f" Сайт: #{g['black_sno']} {g['black_name']} (чёрные) vs #{g['white_sno']} {g['white_name']}")
|
||||
break
|
||||
128
test_round_by_round.py
Normal file
128
test_round_by_round.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Test round-by-round: parse once, truncate results, calculate next round."""
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, '.')
|
||||
|
||||
from swiss_calc.parser import fetch_tournament, fetch_url, parse_round_pairings
|
||||
from swiss_calc.trf_generator import generate_trf
|
||||
from swiss_calc.bbp_wrapper import call_bbp
|
||||
import re
|
||||
|
||||
def norm(n):
|
||||
return re.sub(r'\s+', ' ', n.replace(',', '')).strip()
|
||||
|
||||
BASE = 'https://s1.chess-results.com/tnr1289471.aspx?lan=11'
|
||||
|
||||
print("=" * 60)
|
||||
print("ТЕСТ: пошаговый расчёт всех туров")
|
||||
print("=" * 60)
|
||||
|
||||
# Parse once — get full data with correct SNo, byes, forfeits
|
||||
data = fetch_tournament(BASE)
|
||||
total_rounds = data['current_round']
|
||||
standings = data['standings']
|
||||
|
||||
print(f"Турнир: {data['name'][:60]}")
|
||||
print(f"Сыграно туров: {total_rounds}")
|
||||
print(f"Участников: {len(standings)}")
|
||||
|
||||
# Collect actual pairs for comparison
|
||||
name_to_sno = {norm(s['name']): s['starting_sno'] for s in standings}
|
||||
actual_pairs = {}
|
||||
for rd in range(1, total_rounds + 1):
|
||||
html = fetch_url(f'{BASE}&art=2&rd={rd}&turdet=YES')
|
||||
games = parse_round_pairings(html, rd)
|
||||
for g in games:
|
||||
if g['white_sno'] == 0:
|
||||
g['white_sno'] = name_to_sno.get(norm(g['white_name']), 0)
|
||||
if g['black_sno'] == 0:
|
||||
g['black_sno'] = name_to_sno.get(norm(g['black_name']), 0)
|
||||
actual_pairs[rd] = games
|
||||
print(f" Тур {rd}: {len(games)} пар")
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"ПОШАГОВЫЙ РАСЧЁТ")
|
||||
print(f"{'='*60}")
|
||||
|
||||
overall_matched = 0
|
||||
overall_total = 0
|
||||
|
||||
for next_rd in range(2, total_rounds + 1):
|
||||
completed = next_rd - 1
|
||||
|
||||
# Truncate results to only first `completed` rounds
|
||||
truncated_standings = []
|
||||
for s in standings:
|
||||
full_results = s.get('results', [])
|
||||
full_orig = s.get('_orig_results', [])
|
||||
|
||||
# Take results only up to round `completed`
|
||||
trunc_results = [r for r in full_results if r.get('round', 99) <= completed]
|
||||
trunc_orig = [r for r in full_orig if isinstance(r, dict) and r.get('round', 99) <= completed]
|
||||
|
||||
points = sum(r['score'] for r in trunc_results)
|
||||
|
||||
truncated_standings.append({
|
||||
'rank': s['rank'],
|
||||
'name': s['name'],
|
||||
'fed': s['fed'],
|
||||
'points': points,
|
||||
'starting_sno': s['starting_sno'],
|
||||
'rating': s.get('rating', 0),
|
||||
'results': trunc_results,
|
||||
'_orig_results': trunc_orig,
|
||||
'tb': s.get('tb', []),
|
||||
})
|
||||
|
||||
truncated = {
|
||||
'name': data['name'],
|
||||
'num_rounds': total_rounds,
|
||||
'current_round': completed,
|
||||
'players': data['players'],
|
||||
'standings': truncated_standings,
|
||||
}
|
||||
|
||||
trf = generate_trf(truncated, next_rd, name=data.get('name', 'Test'),
|
||||
use_rank=True, initial_color_white=True)
|
||||
try:
|
||||
bbp_pairs, _ = call_bbp(trf)
|
||||
except Exception as e:
|
||||
print(f" Тур {next_rd}: bbpPairings ОШИБКА — {e}")
|
||||
continue
|
||||
|
||||
# Compare with actual
|
||||
calc_set = set()
|
||||
for w_sno, b_sno in bbp_pairs:
|
||||
if b_sno == 0 or w_sno == 0:
|
||||
continue
|
||||
calc_set.add((min(w_sno, b_sno), max(w_sno, b_sno)))
|
||||
|
||||
real_set = set()
|
||||
for g in actual_pairs.get(next_rd, []):
|
||||
w, b = g['white_sno'], g['black_sno']
|
||||
if w == 0 or b == 0:
|
||||
continue
|
||||
# Only include completed games (not future pairings)
|
||||
if g.get('white_score') is not None:
|
||||
real_set.add((min(w, b), max(w, b)))
|
||||
|
||||
matched = len(calc_set & real_set)
|
||||
total = len(real_set)
|
||||
if total == 0:
|
||||
print(f" Тур {next_rd}: нет сыгранных пар для сравнения")
|
||||
continue
|
||||
|
||||
pct = 100 * matched / total
|
||||
overall_matched += matched
|
||||
overall_total += total
|
||||
|
||||
bar = '█' * int(pct / 5) + '░' * (20 - int(pct / 5))
|
||||
print(f" Тур {next_rd}: {matched}/{total} совпадений [{bar}] {pct:.0f}%")
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
if overall_total > 0:
|
||||
print(f"ИТОГО: {overall_matched}/{overall_total} ({100*overall_matched/overall_total:.0f}%)")
|
||||
else:
|
||||
print(f"ИТОГО: 0 пар")
|
||||
print(f"{'='*60}")
|
||||
Loading…
Add table
Add a link
Reference in a new issue