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 .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r 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 application code
|
||||||
COPY swiss_calc/ /app/swiss_calc/
|
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
|
```bash
|
||||||
# Сборка
|
cd ~/projects/chessCalc
|
||||||
docker compose build
|
docker compose run --rm chess-calc 'https://chess-results.com/tnr1393124.aspx?lan=11'
|
||||||
|
|
||||||
# Расчёт следующего тура
|
|
||||||
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'
|
|
||||||
|
|
||||||
# Показать конкретного игрока
|
# Показать конкретного игрока
|
||||||
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 формате
|
|
||||||
|
|
||||||
## Формат вывода
|
|
||||||
|
|
||||||
```
|
```
|
||||||
**Название турнира**
|
chessCalc/
|
||||||
📋 **Тур 4** — пары
|
├── Dockerfile # python:3.11-slim + bbpPairings
|
||||||
|
├── docker-compose.yml
|
||||||
1. Иванов 🏳️ — Петров (3.0/3.0)
|
├── bbpPairings.exe # FIDE-движок (C++, статическая сборка)
|
||||||
2. Сидоров 🏳️ — Смирнов (2.5/2.5)
|
├── requirements.txt # beautifulsoup4, requests
|
||||||
...
|
├── swiss_calc/
|
||||||
|
│ ├── __main__.py # CLI: приём URL, вызов парсера + движка, вывод
|
||||||
Всего пар: 23
|
│ ├── 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)
|
URL турнира
|
||||||
- **Дополнительно**: реализован базовый Swiss-system алгоритм для случая, если сайт не показывает следующий тур
|
│
|
||||||
- **Форматирование**: Telegram Markdown (поддерживается в Telegram Desktop и мобильной версии)
|
▼
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ 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']:
|
for pairing in next_round['pairings']:
|
||||||
if len(pairing) == 3:
|
if len(pairing) == 3:
|
||||||
p1, p2, color = pairing
|
p1, p2, color = pairing
|
||||||
pairs.append({
|
if color == 'bye':
|
||||||
'white': {'name': p1.name, 'rank': p1.sno, 'rating': p1.rating, 'points': p1.points},
|
pairs.append({
|
||||||
'black': {'name': p2.name, 'rank': p2.sno, 'rating': p2.rating, 'points': p2.points},
|
'board': 0,
|
||||||
'color': color,
|
'bye': {'name': p1.name, 'rank': p1.sno, 'rating': p1.rating, 'points': p1.points},
|
||||||
})
|
})
|
||||||
elif len(pairing) == 5:
|
elif color == 'w':
|
||||||
pairs.append({
|
pairs.append({
|
||||||
'white': {'name': pairing[1] if pairing[2] == 'w' else pairing[4], 'name2': ...},
|
'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({
|
json.dump({
|
||||||
|
'tournament': tournament['name'],
|
||||||
'round': next_round['round'],
|
'round': next_round['round'],
|
||||||
'pairings': pairs,
|
'pairings': pairs,
|
||||||
'source': next_round.get('source', 'algorithm'),
|
'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:
|
for pairing in pairings:
|
||||||
if len(pairing) == 3:
|
if len(pairing) == 3:
|
||||||
p1, p2, color = pairing
|
p1, p2, color = pairing
|
||||||
|
if color == 'bye':
|
||||||
|
lines.append(f" {board}. {p1.name} — BYE (свободен)")
|
||||||
|
board += 1
|
||||||
|
continue
|
||||||
if color == 'w':
|
if color == 'w':
|
||||||
white, black = p1, p2
|
white, black = p1, p2
|
||||||
else:
|
else:
|
||||||
|
|
@ -55,7 +59,9 @@ def format_pairings_table(pairings_data: dict, tournament_name: str) -> str:
|
||||||
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(f"Всего пар: {len(pairings)}")
|
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_")
|
lines.append("_пары с сайта chess-results.com_")
|
||||||
|
|
||||||
return '\n'.join(lines)
|
return '\n'.join(lines)
|
||||||
|
|
|
||||||
|
|
@ -1,121 +1,92 @@
|
||||||
"""
|
"""
|
||||||
Parser for chess-results.com tournament pages.
|
Parser for chess-results.com tournament pages.
|
||||||
|
|
||||||
Fetches and parses:
|
Handles all page states:
|
||||||
- Starting list (players with ratings)
|
- Before pairings: standings with completed rounds
|
||||||
- Round pairings/results
|
- After pairings published: standings + next-round column
|
||||||
- Standings with tiebreakers
|
- After games played: updated standings
|
||||||
|
- art=2 pairings pages in compact format
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import requests
|
import requests
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from typing import Optional
|
from typing import Optional, List, Dict, Tuple
|
||||||
|
|
||||||
HEADERS = {
|
HEADERS = {
|
||||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
'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:
|
def fetch_url(url: str) -> str:
|
||||||
"""Fetch HTML page from chess-results.com."""
|
|
||||||
resp = requests.get(url, headers=HEADERS, timeout=20)
|
resp = requests.get(url, headers=HEADERS, timeout=20)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
resp.encoding = 'utf-8'
|
resp.encoding = 'utf-8'
|
||||||
return resp.text
|
return resp.text
|
||||||
|
|
||||||
|
|
||||||
def parse_result(cell: str):
|
# ── Result parsing ────────────────────────────────────────────
|
||||||
"""Parse a round result cell like '27b1', '15w½', '42b0'.
|
|
||||||
|
|
||||||
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()
|
cell = cell.strip()
|
||||||
if not cell:
|
if not cell:
|
||||||
return None
|
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:
|
if m:
|
||||||
sno = int(m.group(1))
|
sno = int(m.group(1))
|
||||||
color = 'b' if m.group(2) == 'b' else 'w'
|
color = 'w' if m.group(2) == 'w' else 'b'
|
||||||
pts_str = m.group(3).replace(',', '.')
|
pts_str = m.group(3).replace(',', '.').replace('=', '0.5').replace('½', '0.5')
|
||||||
pts = float(pts_str) if '.' in pts_str else (0.5 if pts_str == '½' else float(pts_str))
|
pts = float(pts_str)
|
||||||
return sno, color, pts
|
return sno, color, pts
|
||||||
# Handle forfeit results like '27b+'
|
|
||||||
|
# Forfeit with opponent: '27b+'
|
||||||
m = re.match(r'^(\d+)([bw])([+\-])$', cell)
|
m = re.match(r'^(\d+)([bw])([+\-])$', cell)
|
||||||
if m:
|
if m:
|
||||||
sno = int(m.group(1))
|
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
|
pts = 1.0 if m.group(3) == '+' else 0.0
|
||||||
return sno, color, pts
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_start_list(html: str) -> dict:
|
def parse_next_opponent(cell: str) -> Optional[Tuple[int, str]]:
|
||||||
"""Parse art=5 page: returns dict of {sno: {'name': str, 'rating': int, 'fed': str}}"""
|
"""Parse '4w' or '12b' — next opponent and color."""
|
||||||
soup = BeautifulSoup(html, 'html.parser')
|
m = re.match(r'^(\d+)([bw])$', cell.strip())
|
||||||
players = {}
|
if m:
|
||||||
|
return int(m.group(1)), m.group(2)
|
||||||
# Find the main table with player data (largest table with starting numbers)
|
return None
|
||||||
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_standings(html: str, current_round: int) -> list:
|
# ── Standings parsing ─────────────────────────────────────────
|
||||||
"""Parse art=4 page (standings).
|
|
||||||
|
|
||||||
Returns list of dicts:
|
def _normalize_name(name: str) -> str:
|
||||||
{
|
"""Normalize name for matching: remove commas, collapse whitespace."""
|
||||||
'sno': int,
|
return re.sub(r'\s+', ' ', name.replace(',', '')).strip()
|
||||||
'rank': int,
|
|
||||||
'name': str,
|
def _has_cyrillic(s: str) -> bool:
|
||||||
'fed': str,
|
return bool(re.search(r'[а-яА-ЯёЁ]', s))
|
||||||
'points': float,
|
|
||||||
'results': [(opponent_sno, color, score), ...], # for completed rounds
|
|
||||||
'next_opponent': Optional[int], # if pre-calculated
|
def parse_standings(html: str) -> List[Dict]:
|
||||||
'next_color': Optional[str], # 'w' or 'b'
|
"""Parse standings from art=4 or art=5 page.
|
||||||
'tb': [float, float, float], # tiebreaker values
|
|
||||||
'opponents': [int, ...], # all opponents so far
|
Detects player rows by: first column is a rank number,
|
||||||
}
|
one column has a Cyrillic name.
|
||||||
"""
|
"""
|
||||||
soup = BeautifulSoup(html, 'html.parser')
|
soup = BeautifulSoup(html, 'html.parser')
|
||||||
players = []
|
players = []
|
||||||
|
|
@ -130,115 +101,81 @@ def parse_standings(html: str, current_round: int) -> list:
|
||||||
cells = row.find_all('td')
|
cells = row.find_all('td')
|
||||||
texts = [c.get_text(strip=True) for c in cells]
|
texts = [c.get_text(strip=True) for c in cells]
|
||||||
|
|
||||||
# Filter: first cell should be a rank number
|
if len(texts) < 6:
|
||||||
if not texts or not texts[0].isdigit():
|
|
||||||
continue
|
continue
|
||||||
# Skip if not enough cols for a player row (at least rank + name + results)
|
if not texts[0].isdigit():
|
||||||
if len(texts) < 8:
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
rank = int(texts[0])
|
sno = int(texts[0])
|
||||||
# Usually: rank, (empty), name, fed, rd1, rd2, ..., pts, tb1, tb2, tb3
|
if sno < 1 or sno > 300:
|
||||||
# The empty column sometimes joins with rank
|
|
||||||
col_offset = 0
|
|
||||||
if rank == 0 or rank > 200:
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Find name column
|
# Find name: look for Cyrillic text
|
||||||
# Typical: rank | (empty) | name | fed | results...
|
|
||||||
name = ''
|
name = ''
|
||||||
fed = ''
|
name_idx = -1
|
||||||
name_idx = 1
|
for ci, t in enumerate(texts):
|
||||||
for ci in range(1, min(5, len(texts))):
|
if _has_cyrillic(t) and len(t) > 5:
|
||||||
t = texts[ci]
|
name = t
|
||||||
if t and not t.isdigit() and len(t) > 2 and not t.startswith('http'):
|
name_idx = ci
|
||||||
if ci > 1 or not texts[0].isdigit():
|
break
|
||||||
# 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
|
|
||||||
|
|
||||||
if not name:
|
if not name:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Results start after federation column
|
# Federation is usually the next column, if it looks like a code
|
||||||
# fed is at name_idx+1, so results start at name_idx+2
|
fed = ''
|
||||||
result_start = name_idx + 2 if name_idx + 2 < len(texts) else name_idx + 1
|
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 = []
|
results = []
|
||||||
opponents = []
|
|
||||||
next_opponent = None
|
next_opponent = None
|
||||||
next_color = None
|
next_color = None
|
||||||
pts_found = False
|
points = 0.0
|
||||||
tb_values = []
|
tb_values = []
|
||||||
|
found_points = False
|
||||||
|
|
||||||
# Process each column from result_start
|
for col in data_cols:
|
||||||
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):
|
|
||||||
if not col:
|
if not col:
|
||||||
continue
|
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
|
# Try result
|
||||||
parsed = parse_result(col)
|
res = parse_result(col)
|
||||||
if parsed:
|
if res:
|
||||||
opp, color, pts = parsed
|
opp, color, pts = res
|
||||||
results.append({'opponent': opp, 'color': color, 'score': pts})
|
results.append({'opponent': opp, 'color': color, 'score': pts})
|
||||||
opponents.append(opp)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check for next opponent (just "12w" format without 1/0/½)
|
# Try next opponent
|
||||||
m2 = re.match(r'^(\d+)([bw])$', col)
|
no = parse_next_opponent(col)
|
||||||
if m2:
|
if no:
|
||||||
next_opponent = int(m2.group(1))
|
next_opponent, next_color = no
|
||||||
next_color = m2.group(2)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check if it's the points column
|
# Points: first numeric with optional decimal after results
|
||||||
if re.match(r'^\d+(?:[.,]\d)?$', col) and not pts_found:
|
if re.match(r'^\d+([,.]\d)?$', col) and not found_points:
|
||||||
pts_str = col.replace(',', '.')
|
points = float(col.replace(',', '.'))
|
||||||
points = float(pts_str)
|
found_points = True
|
||||||
pts_found = True
|
|
||||||
pts_col_idx = ci
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Tiebreaker columns (after points)
|
# Tiebreakers (after points)
|
||||||
if pts_found and re.match(r'^\d+(?:[.,]\d)?$', col):
|
if found_points and re.match(r'^\d+([,.]\d+)?$', col):
|
||||||
tb_values.append(float(col.replace(',', '.')))
|
tb_values.append(float(col.replace(',', '.')))
|
||||||
|
|
||||||
# If we didn't find special next-opponent format, check results for unplayed
|
players.append({
|
||||||
# Also check for pre-calculated pairing at position current_round (0-indexed in results)
|
'rank': len(players) + 1, # position in list = current rank
|
||||||
# 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,
|
|
||||||
'name': name,
|
'name': name,
|
||||||
'fed': fed,
|
'fed': fed,
|
||||||
'points': float(texts[-len(tb_values)-1].replace(',', '.')) if texts else 0,
|
'points': points,
|
||||||
|
'starting_sno': sno, # from first column
|
||||||
'results': results,
|
'results': results,
|
||||||
'next_opponent': next_opponent,
|
'next_opponent': next_opponent,
|
||||||
'next_color': next_color,
|
'next_color': next_color,
|
||||||
'tb': tb_values,
|
'tb': tb_values,
|
||||||
'opponents': opponents,
|
})
|
||||||
}
|
|
||||||
players.append(player)
|
|
||||||
|
|
||||||
if players:
|
if players:
|
||||||
break
|
break
|
||||||
|
|
@ -246,254 +183,362 @@ def parse_standings(html: str, current_round: int) -> list:
|
||||||
return players
|
return players
|
||||||
|
|
||||||
|
|
||||||
def parse_round_pairings(html: str, round_num: int) -> list:
|
# ── Round pairings (art=2 compact format) ─────────────────────
|
||||||
"""Parse art=2 page for a specific round.
|
|
||||||
|
|
||||||
Returns list of dicts:
|
def parse_game_row(texts: List[str], board: int) -> Optional[Dict]:
|
||||||
{
|
"""Parse one game row from art=2 page.
|
||||||
'board': int,
|
|
||||||
'white_sno': int,
|
Format A (12 columns — newer tournaments):
|
||||||
'white_name': str,
|
[0]=db_id, [1]=w_sno, [2]=_, [3]=w_name, [4]=w_rating, [5]=w_pts,
|
||||||
'white_rating': int,
|
[6]=result, [7]=b_pts, [8]=_, [9]=b_name, [10]=b_rating, [11]=b_sno
|
||||||
'white_pts': float,
|
|
||||||
'black_sno': int,
|
Format B (10 columns — older tournaments, no SNo in table):
|
||||||
'black_name': str,
|
[0]=seq_id, [1]=_, [2]=w_name, [3]=w_rating, [4]=w_pts,
|
||||||
'black_rating': int,
|
[5]=result, [6]=b_pts, [7]=_, [8]=b_name, [9]=b_rating
|
||||||
'black_pts': float,
|
|
||||||
'result': Optional[str], # '1-0', '½-½', '0-1'
|
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')
|
soup = BeautifulSoup(html, 'html.parser')
|
||||||
pairings = []
|
games = []
|
||||||
|
|
||||||
tables = soup.find_all('table')
|
tables = soup.find_all('table')
|
||||||
for table in tables:
|
for table in tables:
|
||||||
rows = table.find_all('tr')
|
rows = table.find_all('tr')
|
||||||
if len(rows) < 5:
|
board = 0
|
||||||
continue
|
|
||||||
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
cells = row.find_all('td')
|
cells = row.find_all('td')
|
||||||
texts = [c.get_text(strip=True) for c in cells]
|
texts = [c.get_text(strip=True) for c in cells]
|
||||||
if len(texts) < 10:
|
if len(texts) >= 10 and texts[0].isdigit():
|
||||||
continue
|
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
|
if games:
|
||||||
# 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:
|
|
||||||
break
|
break
|
||||||
|
|
||||||
return pairings
|
return games
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tournament meta ───────────────────────────────────────────
|
||||||
|
|
||||||
def extract_tournament_meta(html: str) -> dict:
|
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')
|
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')
|
h2 = soup.find('h2')
|
||||||
if h2:
|
if h2:
|
||||||
info['name'] = h2.get_text(strip=True)
|
info['name'] = h2.get_text(strip=True)
|
||||||
|
|
||||||
# Find "Number of rounds" row in tables - but be specific
|
text = soup.get_text()
|
||||||
# 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
|
|
||||||
|
|
||||||
# Detect current round from navigation: "Тур4/9" or "Round X/Y"
|
# Number of rounds
|
||||||
nav_text = soup.get_text()
|
m = re.search(r'Number of rounds\s*(\d+)', text)
|
||||||
m = re.search(r'Тур(\d+)/\d+|Round\s*(\d+)\s*/\s*\d+', nav_text)
|
|
||||||
if m:
|
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
|
return info
|
||||||
|
|
||||||
|
|
||||||
def detect_current_round(url: str) -> int:
|
# ── Full tournament fetch ─────────────────────────────────────
|
||||||
"""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
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_tournament(url: str) -> dict:
|
def fetch_tournament(url: str) -> dict:
|
||||||
"""Full tournament data fetch.
|
"""Fetch full tournament data from any chess-results.com URL.
|
||||||
|
|
||||||
Returns:
|
Detects current state automatically (any number of completed rounds).
|
||||||
{
|
|
||||||
'name': str,
|
|
||||||
'num_rounds': int,
|
|
||||||
'current_round': int,
|
|
||||||
'players': {sno: {name, rating, fed}},
|
|
||||||
'standings': [...], # players sorted by rank
|
|
||||||
'pairings': {rd: [...]}, # completed round pairings
|
|
||||||
}
|
|
||||||
"""
|
"""
|
||||||
# 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'[&?]art=\d+', '', url)
|
||||||
base_url = re.sub(r'[&?]rd=\d+', '', base_url)
|
base_url = re.sub(r'[&?]rd=\d+', '', base_url)
|
||||||
base_url = re.sub(r'[&?]turdet=\w+', '', base_url)
|
base_url = re.sub(r'[&?]turdet=\w+', '', base_url)
|
||||||
base_url = re.sub(r'[&?]SNode=\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'
|
standings_url = base_url + '&art=4&turdet=YES'
|
||||||
|
|
||||||
try:
|
try:
|
||||||
html = fetch_url(standings_url)
|
html = fetch_url(standings_url)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise RuntimeError(f'Не удалось загрузить турнир: {e}')
|
raise RuntimeError(f'Не удалось загрузить турнир: {e}')
|
||||||
|
|
||||||
# Extract metadata from same HTML
|
|
||||||
meta = extract_tournament_meta(html)
|
meta = extract_tournament_meta(html)
|
||||||
num_rounds = meta['num_rounds']
|
standings = parse_standings(html)
|
||||||
|
|
||||||
# Parse standings
|
if not standings:
|
||||||
standings = parse_standings(html, 0)
|
raise RuntimeError('Не удалось распарсить таблицу положения')
|
||||||
if standings:
|
|
||||||
current_round = len(standings[0].get('results', []))
|
|
||||||
else:
|
|
||||||
current_round = 1
|
|
||||||
|
|
||||||
# Override current_round from navigation if available
|
# Completed rounds: use page metadata ("Положение после тура N")
|
||||||
# Navigation "Тур4/9" means round 4 is current/playing → 3 completed
|
current_round = meta['current_round']
|
||||||
if meta['current_round'] > 1:
|
# Sanity check against actual results
|
||||||
current_round = meta['current_round'] # this is the current playing round
|
max_results = max(len(s['results']) for s in standings)
|
||||||
# But we want the last COMPLETED round for calculations
|
if current_round < 2 and max_results > current_round:
|
||||||
# If results show N entries, N rounds are completed
|
current_round = max_results
|
||||||
|
elif max_results < current_round:
|
||||||
|
current_round = max_results
|
||||||
|
|
||||||
# Reconcile: completed rounds = len(results for first player)
|
# Starting list for ratings
|
||||||
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'
|
|
||||||
try:
|
try:
|
||||||
html_start = fetch_url(start_url)
|
start_html = fetch_url(base_url + '&art=5&turdet=YES')
|
||||||
players = parse_start_list(html_start)
|
start_players = parse_start_list(start_html)
|
||||||
except Exception:
|
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:
|
for s in standings:
|
||||||
# Find by name match
|
sname = _normalize_name(s['name'])
|
||||||
for sno, p in players.items():
|
# Try exact match first
|
||||||
if p['name'].lower() == s['name'].lower():
|
real_sno = name_to_sno.get(sname)
|
||||||
s['rating'] = p['rating']
|
if real_sno is None:
|
||||||
s['starting_sno'] = sno
|
# Fuzzy: find best partial match (first+last name overlap)
|
||||||
break
|
sname_parts = set(sname.split())
|
||||||
else:
|
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['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 {
|
return {
|
||||||
'name': meta['name'],
|
'name': meta['name'],
|
||||||
'num_rounds': num_rounds if num_rounds else 9, # fallback
|
'num_rounds': meta['num_rounds'],
|
||||||
'current_round': current_round,
|
'current_round': current_round,
|
||||||
'players': players,
|
'players': start_players,
|
||||||
'standings': standings,
|
'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), использует
|
Вместо полного max-weight matching (как в JaVaFo), использует
|
||||||
перебор вариантов fold + greedy цветовая оптимизация.
|
перебор вариантов fold + greedy цветовая оптимизация.
|
||||||
|
|
||||||
Для post-round-1 (46 игроков с 1pt) даёт >40% совпадений.
|
Улучшения:
|
||||||
|
- Учёт предыдущих bye (не даём bye повторно)
|
||||||
|
- Downfloaters паруются с верхом следующей группы очков
|
||||||
|
- Абсолютное цветовое предпочтение (|balance| >= 2) — жёсткое правило
|
||||||
|
- Сортировка по очкам + рейтингу для корректного fold
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
@ -31,6 +35,9 @@ class Player:
|
||||||
def color_balance(self): return self.white_count - self.black_count
|
def color_balance(self): return self.white_count - self.black_count
|
||||||
@property
|
@property
|
||||||
def last_color(self): return self.colors[-1] if self.colors else None
|
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:
|
def preferred_color(self) -> str:
|
||||||
if not self.colors:
|
if not self.colors:
|
||||||
|
|
@ -70,18 +77,70 @@ def compute_tiebreakers(players: list, rounds: int) -> None:
|
||||||
# ═══════════════════════════════════
|
# ═══════════════════════════════════
|
||||||
|
|
||||||
def _assign_colors(p1: Player, p2: Player) -> str:
|
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()
|
pref1 = p1.preferred_color()
|
||||||
pref2 = p2.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:
|
if pref1 != pref2:
|
||||||
return pref1
|
return pref1
|
||||||
force1, force2 = p1.color_force(), p2.color_force()
|
|
||||||
if force1 > force2: return pref1
|
# Same preferences — higher rated decides
|
||||||
if force2 > force1: return 'b' if pref1 == 'w' else 'w'
|
if p1.rating >= p2.rating:
|
||||||
if p1.rating >= p2.rating: return pref1
|
return pref1
|
||||||
return 'b' if pref1 == 'w' else 'w'
|
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
|
# ПАРИРОВАНИЕ BRACKET — ПЕРЕБОР OFFSET
|
||||||
# ═══════════════════════════════════
|
# ═══════════════════════════════════
|
||||||
|
|
@ -90,12 +149,15 @@ def _pair_bracket_fold_search(
|
||||||
players: List[Player],
|
players: List[Player],
|
||||||
all_paired: set,
|
all_paired: set,
|
||||||
) -> Tuple[List[Tuple[Player, Player]], List[Player]]:
|
) -> Tuple[List[Tuple[Player, Player]], List[Player]]:
|
||||||
"""Параметризованный fold с перебором offset и floaters.
|
"""Fold pairing with offset search and floater candidates.
|
||||||
|
|
||||||
Для bracket размером N:
|
For a bracket of size N:
|
||||||
1. Если N нечётное — пробуем каждого как флоатера
|
1. If N odd — try each player as a downfloater
|
||||||
2. Для оставшихся M (чётное) — пробуем fold с offset 0..M/2-1
|
2. For the remaining M (even) — try fold offsets 0..M/2-1
|
||||||
3. Выбираем комбинацию с лучшим цветовым качеством
|
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]
|
available = [p for p in players if p.sno not in all_paired]
|
||||||
n = len(available)
|
n = len(available)
|
||||||
|
|
@ -103,13 +165,13 @@ def _pair_bracket_fold_search(
|
||||||
if n < 2:
|
if n < 2:
|
||||||
return [], list(available)
|
return [], list(available)
|
||||||
|
|
||||||
available.sort(key=lambda p: -p.rating)
|
available.sort(key=lambda p: (-p.points, -p.rating))
|
||||||
|
|
||||||
best_pairs = []
|
best_pairs = []
|
||||||
best_floaters = list(available[-1:]) if n % 2 == 1 else []
|
best_floaters = list(available[-1:]) if n % 2 == 1 else []
|
||||||
best_score = -9999
|
best_score = -9999
|
||||||
|
|
||||||
# Кандидаты на флоат
|
# Floater candidates
|
||||||
floater_candidates = [None]
|
floater_candidates = [None]
|
||||||
if n % 2 == 1:
|
if n % 2 == 1:
|
||||||
floater_candidates = range(n)
|
floater_candidates = range(n)
|
||||||
|
|
@ -117,14 +179,14 @@ def _pair_bracket_fold_search(
|
||||||
for fi in floater_candidates:
|
for fi in floater_candidates:
|
||||||
if fi is not None:
|
if fi is not None:
|
||||||
floater = available[fi]
|
floater = available[fi]
|
||||||
rest = available[:fi] + available[fi+1:]
|
rest = available[:fi] + available[fi + 1:]
|
||||||
else:
|
else:
|
||||||
floater = None
|
floater = None
|
||||||
rest = available
|
rest = available
|
||||||
|
|
||||||
m = len(rest)
|
m = len(rest)
|
||||||
|
|
||||||
# Перебор offset
|
# Try fold with different offsets
|
||||||
for offset in range(m // 2):
|
for offset in range(m // 2):
|
||||||
pairs = []
|
pairs = []
|
||||||
ok = True
|
ok = True
|
||||||
|
|
@ -152,17 +214,8 @@ def _pair_bracket_fold_search(
|
||||||
if not ok or len(pairs) < m // 2:
|
if not ok or len(pairs) < m // 2:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Оценка: цветовые несовпадения
|
# Score colour quality
|
||||||
color_score = 0
|
color_score = sum(_color_score(wp, bp, 'w') for wp, bp in pairs)
|
||||||
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
|
|
||||||
|
|
||||||
if color_score > best_score:
|
if color_score > best_score:
|
||||||
best_score = color_score
|
best_score = color_score
|
||||||
|
|
@ -170,11 +223,12 @@ def _pair_bracket_fold_search(
|
||||||
best_floaters = [floater] if floater else []
|
best_floaters = [floater] if floater else []
|
||||||
|
|
||||||
if not best_pairs and n % 2 == 1:
|
if not best_pairs and n % 2 == 1:
|
||||||
# Фолбэк: float последнего
|
# Fallback: float the last player
|
||||||
floater = available[-1]
|
floater = available[-1]
|
||||||
rest = available[:-1]
|
rest = available[:-1]
|
||||||
m = len(rest)
|
m = len(rest)
|
||||||
best_pairs = []
|
best_pairs = []
|
||||||
|
best_floaters = [floater]
|
||||||
for i in range(m // 2):
|
for i in range(m // 2):
|
||||||
a, b = rest[i], rest[m // 2 + i]
|
a, b = rest[i], rest[m // 2 + i]
|
||||||
color = _assign_colors(a, b)
|
color = _assign_colors(a, b)
|
||||||
|
|
@ -182,7 +236,26 @@ def _pair_bracket_fold_search(
|
||||||
best_pairs.append((a, b))
|
best_pairs.append((a, b))
|
||||||
else:
|
else:
|
||||||
best_pairs.append((b, a))
|
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:
|
for wp, bp in best_pairs:
|
||||||
all_paired.add(wp.sno)
|
all_paired.add(wp.sno)
|
||||||
|
|
@ -196,19 +269,28 @@ def _pair_bracket_fold_search(
|
||||||
# ═══════════════════════════════════
|
# ═══════════════════════════════════
|
||||||
|
|
||||||
def fide_swiss_pairing(players: list, current_round: int) -> list:
|
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)
|
sorted_players = sorted(players, key=sort_key)
|
||||||
|
|
||||||
# Bye
|
# Bye — assign to eligible player in lowest score group
|
||||||
if len(sorted_players) % 2 == 1:
|
if len(sorted_players) % 2 == 1:
|
||||||
groups = defaultdict(list)
|
groups = defaultdict(list)
|
||||||
for p in sorted_players:
|
for p in sorted_players:
|
||||||
groups[p.points].append(p)
|
groups[p.points].append(p)
|
||||||
lowest = sorted(groups[min(groups.keys())], key=lambda p: p.rating)
|
# Lowest score group, by rating ascending, excluding previous bye receivers
|
||||||
bye_player = lowest[0]
|
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]
|
sorted_players = [p for p in sorted_players if p.sno != bye_player.sno]
|
||||||
|
|
||||||
# Score brackets
|
# Create score brackets
|
||||||
brackets = []
|
brackets = []
|
||||||
i = 0
|
i = 0
|
||||||
while i < len(sorted_players):
|
while i < len(sorted_players):
|
||||||
|
|
@ -224,17 +306,72 @@ def fide_swiss_pairing(players: list, current_round: int) -> list:
|
||||||
downfloaters = []
|
downfloaters = []
|
||||||
|
|
||||||
for group in brackets:
|
for group in brackets:
|
||||||
group_avail = [p for p in group if p.sno not in all_paired]
|
# Available players in this bracket
|
||||||
for df in downfloaters:
|
bracket_avail = [p for p in group if p.sno not in all_paired]
|
||||||
if df.sno not in all_paired:
|
|
||||||
group_avail.append(df)
|
|
||||||
|
|
||||||
if len(group_avail) < 2:
|
# Downfloaters from above (have more points than this bracket)
|
||||||
downfloaters = group_avail
|
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
|
continue
|
||||||
|
|
||||||
pairs, downfloaters = _pair_bracket_fold_search(group_avail, all_paired)
|
# Priority: pair each downfloater with a bracket member
|
||||||
all_pairs.extend(pairs)
|
# (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
|
return all_pairs
|
||||||
|
|
||||||
|
|
@ -249,37 +386,73 @@ def calculate_next_round(tournament_data: dict) -> dict:
|
||||||
next_round = current_round + 1
|
next_round = current_round + 1
|
||||||
|
|
||||||
player_map = {}
|
player_map = {}
|
||||||
|
sno_to_player = {s.get('starting_sno', s['rank']): None for s in standings}
|
||||||
for s in standings:
|
for s in standings:
|
||||||
rank = s['rank']
|
rank = s['rank']
|
||||||
|
sno = s.get('starting_sno', rank)
|
||||||
p = Player(
|
p = Player(
|
||||||
sno=s.get('starting_sno', rank),
|
sno=sno,
|
||||||
name=s['name'], rating=s.get('rating', 0),
|
name=s['name'], rating=s.get('rating', 0),
|
||||||
fed=s['fed'], points=s['points'], results=s['results'])
|
fed=s['fed'], points=s['points'], results=s['results'])
|
||||||
p.rank = rank
|
p.rank = rank
|
||||||
p.tb = s.get('tb', [])
|
p.tb = s.get('tb', [])
|
||||||
player_map[rank] = p
|
player_map[rank] = p
|
||||||
|
sno_to_player[sno] = p
|
||||||
|
|
||||||
# Предпочитаем предрассчитанные пары с сайта
|
# Try bbpPairings first (FIDE 2025 Dutch System engine)
|
||||||
has_calculated = any(s.get('next_opponent') for s in standings)
|
bbp_error = None
|
||||||
if has_calculated:
|
try:
|
||||||
pairings = []
|
from .trf_generator import generate_trf
|
||||||
paired = set()
|
from .bbp_wrapper import call_bbp
|
||||||
for s in standings:
|
|
||||||
rank = s['rank']
|
trf = generate_trf(
|
||||||
if rank in paired: continue
|
tournament_data, next_round,
|
||||||
opp_rank = s.get('next_opponent')
|
name=tournament_data.get('name', 'Chess Tournament'),
|
||||||
if opp_rank and opp_rank in player_map and opp_rank not in paired:
|
use_rank=False,
|
||||||
color = s.get('next_color', 'w')
|
initial_color_white=False,
|
||||||
p1, p2 = player_map[rank], player_map[opp_rank]
|
)
|
||||||
if color == 'w':
|
bbp_pairs, _ = call_bbp(trf)
|
||||||
pairings.append((p1, p2, 'w'))
|
|
||||||
|
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:
|
else:
|
||||||
pairings.append((p2, p1, 'w'))
|
bp = sno_to_player.get(b_sno)
|
||||||
paired.add(rank); paired.add(opp_rank)
|
if bp is None:
|
||||||
return {'round': next_round, 'pairings': pairings,
|
continue
|
||||||
'players': player_map, 'source': 'chess_results_precalculated'}
|
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)
|
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,
|
return {'round': next_round,
|
||||||
'pairings': [(wp, bp, 'w') for wp, bp in raw],
|
'pairings': pairings,
|
||||||
'players': player_map, 'source': 'swiss_algorithm'}
|
'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