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'
This commit is contained in:
commit
cf7fafd2ba
10 changed files with 1093 additions and 0 deletions
0
swiss_calc/__init__.py
Normal file
0
swiss_calc/__init__.py
Normal file
132
swiss_calc/__main__.py
Normal file
132
swiss_calc/__main__.py
Normal file
|
|
@ -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 <URL>
|
||||
python -m swiss_calc --standings <URL>
|
||||
"""
|
||||
|
||||
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()
|
||||
84
swiss_calc/display.py
Normal file
84
swiss_calc/display.py
Normal file
|
|
@ -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)
|
||||
499
swiss_calc/parser.py
Normal file
499
swiss_calc/parser.py
Normal file
|
|
@ -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,
|
||||
}
|
||||
285
swiss_calc/swiss.py
Normal file
285
swiss_calc/swiss.py
Normal file
|
|
@ -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'}
|
||||
Loading…
Add table
Add a link
Reference in a new issue