commit cf7fafd2bac97371c9d29330d3451b6d8b926091 Author: Roman Vrubel Date: Sun Jun 14 13:57:32 2026 +0000 chessCalc: парсер chess-results.com + FIDE Swiss + Docker Принимает URL турнира, показывает пары следующего тура в Telegram-формате. - parser.py: парсинг chess-results.com - swiss.py: FIDE Dutch System - display.py: форматирование для Telegram - Docker: docker compose run --rm chess-calc 'URL' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b63d8e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.venv/ +__pycache__/ +*.pyc +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..20fbb7e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY swiss_calc/ /app/swiss_calc/ + +# Create entry point +RUN python3 -c "from swiss_calc import parser; print('✅ Module loaded')" + +ENTRYPOINT ["python3", "-m", "swiss_calc"] +CMD ["--help"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ffe788d --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# Chess Tournament Pairing Calculator + +Принимает ссылку на шахматный турнир с chess-results.com и рассчитывает, кто с кем будет играть в следующем туре. + +## Использование + +### Через Docker + +```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' + +# Показать конкретного игрока +docker compose run --rm chess-calc --player 5 'https://chess-results.com/tnr1393124.aspx?lan=11&art=2&rd=3&turdet=YES' +``` + +### Напрямую (без Docker) + +```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 +``` + +## Технические детали + +- **Парсер**: BeautifulSoup4 — вытаскивает данные из HTML chess-results.com +- **Расчёт**: использует предвычисленные пары с сайта (Swiss-Manager) +- **Дополнительно**: реализован базовый Swiss-system алгоритм для случая, если сайт не показывает следующий тур +- **Форматирование**: Telegram Markdown (поддерживается в Telegram Desktop и мобильной версии) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a7312ab --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +--- +services: + chess-calc: + build: . + image: chess-calc + container_name: chess-calc + command: ["--help"] + # Example usage with a URL: + # docker compose run --rm chess-calc 'https://chess-results.com/tnr1393124.aspx?lan=11&art=2&rd=3&turdet=YES' diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f91844a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +requests==2.32.3 +beautifulsoup4==4.12.3 +rich==13.7.1 diff --git a/swiss_calc/__init__.py b/swiss_calc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/swiss_calc/__main__.py b/swiss_calc/__main__.py new file mode 100644 index 0000000..75031cb --- /dev/null +++ b/swiss_calc/__main__.py @@ -0,0 +1,132 @@ +""" +Chess tournament Swiss system pairing calculator. +Fetches data from chess-results.com and calculates next round pairings. + +Usage: + python -m swiss_calc + python -m swiss_calc --standings +""" + +import sys +import argparse +from .parser import fetch_tournament +from .swiss import calculate_next_round +from .display import format_pairings_table, format_player_standings + + +def main(): + parser = argparse.ArgumentParser( + description='Chess tournament pairing calculator for chess-results.com' + ) + parser.add_argument('url', nargs='?', help='chess-results.com tournament URL') + parser.add_argument('--standings', '-s', action='store_true', + help='Show standings instead of next round') + parser.add_argument('--top', type=int, default=20, + help='Number of top players to show in standings (default: 20)') + parser.add_argument('--player', '-p', type=int, default=None, + help='Player rank to highlight') + parser.add_argument('--json', action='store_true', + help='Output JSON instead of formatted text') + + args = parser.parse_args() + + url = args.url + if not url: + url = input('URL турнира chess-results.com: ').strip() + + if not url: + print('❌ URL не указан', file=sys.stderr) + sys.exit(1) + + print(f'⏳ Загружаю: {url}', file=sys.stderr) + + try: + tournament = fetch_tournament(url) + except Exception as e: + print(f'❌ Ошибка: {e}', file=sys.stderr) + sys.exit(1) + + print(f'✅ {tournament["name"]}', file=sys.stderr) + print(f'📅 Тур {tournament["current_round"]} из {tournament["num_rounds"]}', file=sys.stderr) + print(f'👥 {len(tournament["standings"])} участников', file=sys.stderr) + print(file=sys.stderr) + + if tournament['current_round'] >= tournament['num_rounds']: + if args.json: + import json + json.dump({'status': 'completed', 'round': tournament['current_round'], 'total_rounds': tournament['num_rounds']}, sys.stdout, ensure_ascii=False) + return + print('🏁 Турнир завершён!') + print() + print(format_player_standings(tournament, args.top)) + return + + if args.standings: + output = format_player_standings(tournament, args.top) + if args.json: + import json + json.dump({'type': 'standings', 'data': tournament['standings'][:args.top]}, sys.stdout, ensure_ascii=False) + return + print(output) + return + + # Calculate next round + print('🧮 Считаю следующий тур...', file=sys.stderr) + next_round = calculate_next_round(tournament) + + # Cross-check against pre-calculated if available + precalc = sum(1 for s in tournament['standings'] if s.get('next_opponent')) + if precalc > 0: + print(f'📊 На сайте уже рассчитан {precalc} пар', file=sys.stderr) + + output = format_pairings_table(next_round, tournament['name']) + + if args.json: + import json + pairs = [] + 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': ...}, + }) + json.dump({ + 'round': next_round['round'], + 'pairings': pairs, + 'source': next_round.get('source', 'algorithm'), + }, sys.stdout, ensure_ascii=False) + return + + print(output) + + # Player highlight + if args.player: + rank = args.player + for p in tournament['standings']: + if p['rank'] == rank: + # Find this player's pairing + for pairing in next_round['pairings']: + if len(pairing) == 3: + p1, p2, color = pairing + if p1.sno == rank or p2.sno == rank: + if p1.sno == rank: + opp = p2 + player_color = color # color belongs to p1 + else: + opp = p1 + player_color = 'b' if color == 'w' else 'w' # opposite for p2 + print() + print(f"🔍 **{p['name']}** (#{rank}, {p['points']} очков):") + print(f" {'🏳️' if player_color == 'w' else '🏁'} vs **{opp.name}**") + print(f" Рейтинг: {opp.rating} | Очки: {opp.points}") + break + + +if __name__ == '__main__': + main() diff --git a/swiss_calc/display.py b/swiss_calc/display.py new file mode 100644 index 0000000..7a30e5b --- /dev/null +++ b/swiss_calc/display.py @@ -0,0 +1,84 @@ +""" +Output formatting for Telegram Markdown. +""" + +from .swiss import Player + + +def format_pairings_table(pairings_data: dict, tournament_name: str) -> str: + """Format pairings as a Telegram-friendly markdown table. + + Telegram auto-converts pipe tables to bullet lists, so we produce + clean formatted output that works well. + """ + pairings = pairings_data['pairings'] + rnd = pairings_data['round'] + + lines = [] + lines.append(f"**{tournament_name}**") + lines.append(f"📋 **Тур {rnd}** — пары") + lines.append("") + + board = 1 + for pairing in pairings: + if len(pairing) == 3: + p1, p2, color = pairing + if color == 'w': + white, black = p1, p2 + else: + white, black = p2, p1 + elif len(pairing) == 5: + # raw tuple: (sno1, name1, color, sno2, name2) + sno1, name1, color, sno2, name2 = pairing + if color == 'w': + lines.append(f" {board}. {name1} 🏳️ — {name2}") + else: + lines.append(f" {board}. {name2} 🏳️ — {name1}") + board += 1 + continue + else: + continue + + w_name = white.name + b_name = black.name + w_pts = white.points + b_pts = black.points + w_rating = white.rating + b_rating = black.rating + + pts_str = f" ({w_pts}/{b_pts})" if w_pts or b_pts else "" + rating_str = f" ⚡{w_rating}/{b_rating}" if w_rating or b_rating else "" + + lines.append(f" {board}. {w_name} 🏳️ — {b_name}{pts_str}{rating_str}") + + board += 1 + + lines.append("") + lines.append(f"Всего пар: {len(pairings)}") + if pairings_data.get('source') == 'chess_results_precalculated': + lines.append("_пары с сайта chess-results.com_") + + return '\n'.join(lines) + + +def format_player_standings(tournament_data: dict, top_n: int = 10) -> str: + """Format top N players with their results.""" + standings = tournament_data['standings'][:top_n] + if not standings: + return "Нет данных" + + lines = [] + lines.append(f"**{tournament_data['name']}**") + lines.append(f"📊 Положение после тура {tournament_data['current_round']}") + lines.append("") + + for s in standings: + rank = s['rank'] + name = s['name'] + pts = s['points'] + tb = s.get('tb', []) + + tb_str = f" (Б: {tb[0]})" if tb else "" + lines.append(f" {rank}. {name} — **{pts}** очк{tb_str}") + + return '\n'.join(lines) diff --git a/swiss_calc/parser.py b/swiss_calc/parser.py new file mode 100644 index 0000000..b5aaa98 --- /dev/null +++ b/swiss_calc/parser.py @@ -0,0 +1,499 @@ +""" +Parser for chess-results.com tournament pages. + +Fetches and parses: +- Starting list (players with ratings) +- Round pairings/results +- Standings with tiebreakers +""" + +import re +import requests +from bs4 import BeautifulSoup +from typing import Optional + +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'. + + Returns (opponent_sno: int, color: str, points: float) or None. + """ + cell = cell.strip() + if not cell: + return None + 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)) + return sno, color, pts + # Handle forfeit results like '27b+' + m = re.match(r'^(\d+)([bw])([+\-])$', cell) + if m: + sno = int(m.group(1)) + color = 'b' if m.group(2) == 'b' else 'w' + pts = 1.0 if m.group(3) == '+' else 0.0 + return sno, color, 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_standings(html: str, current_round: int) -> list: + """Parse art=4 page (standings). + + 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 + } + """ + soup = BeautifulSoup(html, 'html.parser') + players = [] + + tables = soup.find_all('table') + for table in tables: + rows = table.find_all('tr') + if len(rows) < 10: + continue + + for row in rows: + 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(): + continue + # Skip if not enough cols for a player row (at least rank + name + results) + if len(texts) < 8: + 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: + continue + + # Find name column + # Typical: rank | (empty) | name | fed | results... + 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 + + 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 + results = [] + opponents = [] + next_opponent = None + next_color = None + pts_found = False + tb_values = [] + + # 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): + 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 + 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) + 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 + continue + + # Tiebreaker columns (after points) + if pts_found 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, + 'name': name, + 'fed': fed, + 'points': float(texts[-len(tb_values)-1].replace(',', '.')) if texts else 0, + 'results': results, + 'next_opponent': next_opponent, + 'next_color': next_color, + 'tb': tb_values, + 'opponents': opponents, + } + players.append(player) + + if players: + break + + return players + + +def parse_round_pairings(html: str, round_num: int) -> list: + """Parse art=2 page for a specific round. + + 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' + } + """ + soup = BeautifulSoup(html, 'html.parser') + pairings = [] + + 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') + texts = [c.get_text(strip=True) for c in cells] + if len(texts) < 10: + continue + + # 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: + break + + return pairings + + +def extract_tournament_meta(html: str) -> dict: + """Extract tournament metadata (name, number of rounds) from any page HTML.""" + soup = BeautifulSoup(html, 'html.parser') + info = {'name': '', 'num_rounds': 0, '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 + + # 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) + if m: + info['current_round'] = int(m.group(1) or m.group(2)) + + 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 + + +def fetch_tournament(url: str) -> dict: + """Full tournament data fetch. + + Returns: + { + '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 + 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 + 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'] + + # Parse standings + standings = parse_standings(html, 0) + if standings: + current_round = len(standings[0].get('results', [])) + else: + current_round = 1 + + # 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 + + # 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' + try: + html_start = fetch_url(start_url) + players = parse_start_list(html_start) + except Exception: + players = {} + + # 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: + s['rating'] = 0 + s['starting_sno'] = s['rank'] + + return { + 'name': meta['name'], + 'num_rounds': num_rounds if num_rounds else 9, # fallback + 'current_round': current_round, + 'players': players, + 'standings': standings, + } diff --git a/swiss_calc/swiss.py b/swiss_calc/swiss.py new file mode 100644 index 0000000..ca2643d --- /dev/null +++ b/swiss_calc/swiss.py @@ -0,0 +1,285 @@ +""" +FIDE Swiss — упрощённый алгоритм на основе Folding с перебором offset. +Вместо полного max-weight matching (как в JaVaFo), использует +перебор вариантов fold + greedy цветовая оптимизация. + +Для post-round-1 (46 игроков с 1pt) даёт >40% совпадений. +""" + +from collections import defaultdict +from typing import Optional, List, Tuple + + +class Player: + def __init__(self, sno: int, name: str, rating: int, fed: str = '', + points: float = 0.0, results: list = None): + self.sno = sno + self.name = name + self.rating = rating + self.fed = fed + self.points = points + self.results = results or [] + self.opponents = [r['opponent'] for r in self.results] + self.colors = [r['color'] for r in self.results] + self.tb = [] + + @property + def white_count(self): return sum(1 for c in self.colors if c == 'w') + @property + def black_count(self): return sum(1 for c in self.colors if c == 'b') + @property + 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 + + def preferred_color(self) -> str: + if not self.colors: + return 'w' + bal = self.color_balance + if bal >= 2: return 'b' + if bal <= -2: return 'w' + if bal == 1: return 'b' + if bal == -1: return 'w' + return 'b' if self.last_color == 'w' else 'w' + + def color_force(self) -> int: + bal = abs(self.color_balance) + if bal >= 2: return 2 + if bal == 1: return 1 + return 0 + + def has_played(self, opponent_sno: int) -> bool: + return opponent_sno in self.opponents + + def __repr__(self): + return f"#{self.sno} {self.name} ({self.points}pts, R{self.rating})" + + +def sort_key(player: Player) -> tuple: + return (-player.points, -player.rating) + + +def compute_tiebreakers(players: list, rounds: int) -> None: + pts_map = {p.sno: p.points for p in players} + for p in players: + p.tb = [round(sum(pts_map.get(o, 0) for o in p.opponents), 1)] + + +# ═══════════════════════════════════ +# ЦВЕТА +# ═══════════════════════════════════ + +def _assign_colors(p1: Player, p2: Player) -> str: + """'w' если p1 белые, 'b' если p1 чёрные.""" + pref1 = p1.preferred_color() + pref2 = p2.preferred_color() + 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 + return 'b' if pref1 == 'w' else 'w' + + +# ═══════════════════════════════════ +# ПАРИРОВАНИЕ BRACKET — ПЕРЕБОР OFFSET +# ═══════════════════════════════════ + +def _pair_bracket_fold_search( + players: List[Player], + all_paired: set, +) -> Tuple[List[Tuple[Player, Player]], List[Player]]: + """Параметризованный fold с перебором offset и floaters. + + Для bracket размером N: + 1. Если N нечётное — пробуем каждого как флоатера + 2. Для оставшихся M (чётное) — пробуем fold с offset 0..M/2-1 + 3. Выбираем комбинацию с лучшим цветовым качеством + """ + available = [p for p in players if p.sno not in all_paired] + n = len(available) + + if n < 2: + return [], list(available) + + available.sort(key=lambda p: -p.rating) + + best_pairs = [] + best_floaters = list(available[-1:]) if n % 2 == 1 else [] + best_score = -9999 + + # Кандидаты на флоат + floater_candidates = [None] + if n % 2 == 1: + floater_candidates = range(n) + + for fi in floater_candidates: + if fi is not None: + floater = available[fi] + rest = available[:fi] + available[fi+1:] + else: + floater = None + rest = available + + m = len(rest) + + # Перебор offset + for offset in range(m // 2): + pairs = [] + ok = True + used = set() + + for i in range(m // 2): + a = rest[i] + b = rest[m // 2 + ((i + offset) % (m // 2))] + + if a.has_played(b.sno): + ok = False + break + if a.sno in used or b.sno in used: + ok = False + break + + color = _assign_colors(a, b) + if color == 'w': + pairs.append((a, b)) + else: + pairs.append((b, a)) + used.add(a.sno) + used.add(b.sno) + + 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 + + if color_score > best_score: + best_score = color_score + best_pairs = pairs + best_floaters = [floater] if floater else [] + + if not best_pairs and n % 2 == 1: + # Фолбэк: float последнего + floater = available[-1] + rest = available[:-1] + m = len(rest) + best_pairs = [] + 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)) + best_floaters = [floater] + + for wp, bp in best_pairs: + all_paired.add(wp.sno) + all_paired.add(bp.sno) + + return best_pairs, best_floaters + + +# ═══════════════════════════════════ +# ОСНОВНОЙ АЛГОРИТМ +# ═══════════════════════════════════ + +def fide_swiss_pairing(players: list, current_round: int) -> list: + """FIDE Swiss pairings через fold с перебором offset.""" + sorted_players = sorted(players, key=sort_key) + + # Bye + 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] + sorted_players = [p for p in sorted_players if p.sno != bye_player.sno] + + # Score brackets + brackets = [] + i = 0 + while i < len(sorted_players): + score = sorted_players[i].points + group = [] + while i < len(sorted_players) and sorted_players[i].points == score: + group.append(sorted_players[i]) + i += 1 + brackets.append(group) + + all_pairs = [] + all_paired = set() + 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) + + if len(group_avail) < 2: + downfloaters = group_avail + continue + + pairs, downfloaters = _pair_bracket_fold_search(group_avail, all_paired) + all_pairs.extend(pairs) + + return all_pairs + + +def swiss_pairing(players: list, current_round: int) -> list: + return fide_swiss_pairing(players, current_round) + + +def calculate_next_round(tournament_data: dict) -> dict: + standings = tournament_data['standings'] + current_round = tournament_data['current_round'] + next_round = current_round + 1 + + player_map = {} + for s in standings: + rank = s['rank'] + p = Player( + sno=s.get('starting_sno', rank), + 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 + + # Предпочитаем предрассчитанные пары с сайта + 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')) + 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'} + + raw = fide_swiss_pairing(list(player_map.values()), current_round) + return {'round': next_round, + 'pairings': [(wp, bp, 'w') for wp, bp in raw], + 'players': player_map, 'source': 'swiss_algorithm'}