2026-06-14 13:57:32 +00:00
|
|
|
|
"""
|
|
|
|
|
|
Parser for chess-results.com tournament pages.
|
|
|
|
|
|
|
2026-06-14 20:29:27 +00:00
|
|
|
|
Handles all page states:
|
|
|
|
|
|
- Before pairings: standings with completed rounds
|
|
|
|
|
|
- After pairings published: standings + next-round column
|
|
|
|
|
|
- After games played: updated standings
|
|
|
|
|
|
- art=2 pairings pages in compact format
|
2026-06-14 13:57:32 +00:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
|
import requests
|
|
|
|
|
|
from bs4 import BeautifulSoup
|
2026-06-14 20:29:27 +00:00
|
|
|
|
from typing import Optional, List, Dict, Tuple
|
2026-06-14 13:57:32 +00:00
|
|
|
|
|
|
|
|
|
|
HEADERS = {
|
|
|
|
|
|
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fetch_url(url: str) -> str:
|
|
|
|
|
|
resp = requests.get(url, headers=HEADERS, timeout=20)
|
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
|
resp.encoding = 'utf-8'
|
|
|
|
|
|
return resp.text
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 20:29:27 +00:00
|
|
|
|
# ── Result parsing ────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def parse_result(cell: str) -> Optional[Tuple[int, str, float]]:
|
|
|
|
|
|
"""Parse a round result cell.
|
2026-06-14 13:57:32 +00:00
|
|
|
|
|
2026-06-14 20:29:27 +00:00
|
|
|
|
Returns (opponent_sno, color, score) or None.
|
|
|
|
|
|
sno=0 means bye/forfeit; color='-' means no color.
|
2026-06-14 13:57:32 +00:00
|
|
|
|
"""
|
|
|
|
|
|
cell = cell.strip()
|
|
|
|
|
|
if not cell:
|
|
|
|
|
|
return None
|
2026-06-14 20:29:27 +00:00
|
|
|
|
|
|
|
|
|
|
# Standard: '27b1', '15w½'
|
|
|
|
|
|
m = re.match(r'^(\d+)([bw])([10½=]+|0[,.]5)$', cell)
|
2026-06-14 13:57:32 +00:00
|
|
|
|
if m:
|
|
|
|
|
|
sno = int(m.group(1))
|
2026-06-14 20:29:27 +00:00
|
|
|
|
color = 'w' if m.group(2) == 'w' else 'b'
|
|
|
|
|
|
pts_str = m.group(3).replace(',', '.').replace('=', '0.5').replace('½', '0.5')
|
|
|
|
|
|
pts = float(pts_str)
|
2026-06-14 13:57:32 +00:00
|
|
|
|
return sno, color, pts
|
2026-06-14 20:29:27 +00:00
|
|
|
|
|
|
|
|
|
|
# Forfeit with opponent: '27b+'
|
2026-06-14 13:57:32 +00:00
|
|
|
|
m = re.match(r'^(\d+)([bw])([+\-])$', cell)
|
|
|
|
|
|
if m:
|
|
|
|
|
|
sno = int(m.group(1))
|
2026-06-14 20:29:27 +00:00
|
|
|
|
color = 'w' if m.group(2) == 'w' else 'b'
|
2026-06-14 13:57:32 +00:00
|
|
|
|
pts = 1.0 if m.group(3) == '+' else 0.0
|
|
|
|
|
|
return sno, color, pts
|
2026-06-14 20:29:27 +00:00
|
|
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
|
|
2026-06-14 13:57:32 +00:00
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 20:29:27 +00:00
|
|
|
|
def parse_next_opponent(cell: str) -> Optional[Tuple[int, str]]:
|
|
|
|
|
|
"""Parse '4w' or '12b' — next opponent and color."""
|
|
|
|
|
|
m = re.match(r'^(\d+)([bw])$', cell.strip())
|
|
|
|
|
|
if m:
|
|
|
|
|
|
return int(m.group(1)), m.group(2)
|
|
|
|
|
|
return None
|
2026-06-14 13:57:32 +00:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 20:29:27 +00:00
|
|
|
|
# ── Standings parsing ─────────────────────────────────────────
|
2026-06-14 13:57:32 +00:00
|
|
|
|
|
2026-06-14 20:29:27 +00:00
|
|
|
|
def _normalize_name(name: str) -> str:
|
|
|
|
|
|
"""Normalize name for matching: remove commas, collapse whitespace."""
|
|
|
|
|
|
return re.sub(r'\s+', ' ', name.replace(',', '')).strip()
|
2026-06-14 13:57:32 +00:00
|
|
|
|
|
2026-06-14 20:29:27 +00:00
|
|
|
|
def _has_cyrillic(s: str) -> bool:
|
|
|
|
|
|
return bool(re.search(r'[а-яА-ЯёЁ]', s))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-19 14:51:15 +00:00
|
|
|
|
def _is_player_name(t: str) -> bool:
|
|
|
|
|
|
"""Check if text looks like a player name (Latin or Cyrillic)."""
|
|
|
|
|
|
if len(t) <= 5:
|
|
|
|
|
|
return False
|
|
|
|
|
|
if re.match(r'^\d+$', t):
|
|
|
|
|
|
return False
|
|
|
|
|
|
if re.match(r'^[A-Z]{3}$', t):
|
|
|
|
|
|
return False
|
|
|
|
|
|
if re.match(r'^\d+[bw][½\d]?$', t):
|
|
|
|
|
|
return False
|
|
|
|
|
|
return bool(re.search(r'[A-Za-zА-Яа-я]', t))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 20:29:27 +00:00
|
|
|
|
def parse_standings(html: str) -> List[Dict]:
|
|
|
|
|
|
"""Parse standings from art=4 or art=5 page.
|
|
|
|
|
|
|
|
|
|
|
|
Detects player rows by: first column is a rank number,
|
|
|
|
|
|
one column has a Cyrillic name.
|
2026-06-14 13:57:32 +00:00
|
|
|
|
"""
|
|
|
|
|
|
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]
|
|
|
|
|
|
|
2026-06-14 20:29:27 +00:00
|
|
|
|
if len(texts) < 6:
|
2026-06-14 13:57:32 +00:00
|
|
|
|
continue
|
2026-06-14 20:29:27 +00:00
|
|
|
|
if not texts[0].isdigit():
|
2026-06-14 13:57:32 +00:00
|
|
|
|
continue
|
|
|
|
|
|
|
2026-06-14 20:29:27 +00:00
|
|
|
|
sno = int(texts[0])
|
|
|
|
|
|
if sno < 1 or sno > 300:
|
2026-06-14 13:57:32 +00:00
|
|
|
|
continue
|
|
|
|
|
|
|
2026-06-19 14:51:15 +00:00
|
|
|
|
# Find name: look for player name (Latin or Cyrillic)
|
2026-06-14 13:57:32 +00:00
|
|
|
|
name = ''
|
2026-06-14 20:29:27 +00:00
|
|
|
|
name_idx = -1
|
|
|
|
|
|
for ci, t in enumerate(texts):
|
2026-06-19 14:51:15 +00:00
|
|
|
|
if _is_player_name(t):
|
2026-06-14 20:29:27 +00:00
|
|
|
|
name = t
|
|
|
|
|
|
name_idx = ci
|
|
|
|
|
|
break
|
2026-06-14 13:57:32 +00:00
|
|
|
|
if not name:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
2026-06-14 20:29:27 +00:00
|
|
|
|
# Federation is usually the next column, if it looks like a code
|
|
|
|
|
|
fed = ''
|
|
|
|
|
|
if name_idx + 1 < len(texts):
|
|
|
|
|
|
t = texts[name_idx + 1]
|
|
|
|
|
|
if re.match(r'^[A-Z]{3}$', t):
|
|
|
|
|
|
fed = t
|
|
|
|
|
|
|
|
|
|
|
|
# Process columns after name+federation for results and points
|
|
|
|
|
|
data_cols = texts[name_idx + (2 if fed else 1):]
|
|
|
|
|
|
|
2026-06-14 13:57:32 +00:00
|
|
|
|
results = []
|
|
|
|
|
|
next_opponent = None
|
|
|
|
|
|
next_color = None
|
2026-06-14 20:29:27 +00:00
|
|
|
|
points = 0.0
|
2026-06-14 13:57:32 +00:00
|
|
|
|
tb_values = []
|
2026-06-14 20:29:27 +00:00
|
|
|
|
found_points = False
|
2026-06-14 13:57:32 +00:00
|
|
|
|
|
2026-06-14 20:29:27 +00:00
|
|
|
|
for col in data_cols:
|
2026-06-14 13:57:32 +00:00
|
|
|
|
if not col:
|
|
|
|
|
|
continue
|
2026-06-14 20:29:27 +00:00
|
|
|
|
|
|
|
|
|
|
# Try result
|
|
|
|
|
|
res = parse_result(col)
|
|
|
|
|
|
if res:
|
|
|
|
|
|
opp, color, pts = res
|
2026-06-14 13:57:32 +00:00
|
|
|
|
results.append({'opponent': opp, 'color': color, 'score': pts})
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
2026-06-14 20:29:27 +00:00
|
|
|
|
# Try next opponent
|
|
|
|
|
|
no = parse_next_opponent(col)
|
|
|
|
|
|
if no:
|
|
|
|
|
|
next_opponent, next_color = no
|
2026-06-14 13:57:32 +00:00
|
|
|
|
continue
|
|
|
|
|
|
|
2026-06-14 20:29:27 +00:00
|
|
|
|
# Points: first numeric with optional decimal after results
|
|
|
|
|
|
if re.match(r'^\d+([,.]\d)?$', col) and not found_points:
|
|
|
|
|
|
points = float(col.replace(',', '.'))
|
|
|
|
|
|
found_points = True
|
2026-06-14 13:57:32 +00:00
|
|
|
|
continue
|
|
|
|
|
|
|
2026-06-14 20:29:27 +00:00
|
|
|
|
# Tiebreakers (after points)
|
|
|
|
|
|
if found_points and re.match(r'^\d+([,.]\d+)?$', col):
|
2026-06-14 13:57:32 +00:00
|
|
|
|
tb_values.append(float(col.replace(',', '.')))
|
|
|
|
|
|
|
2026-06-14 20:29:27 +00:00
|
|
|
|
players.append({
|
|
|
|
|
|
'rank': len(players) + 1, # position in list = current rank
|
2026-06-14 13:57:32 +00:00
|
|
|
|
'name': name,
|
|
|
|
|
|
'fed': fed,
|
2026-06-14 20:29:27 +00:00
|
|
|
|
'points': points,
|
|
|
|
|
|
'starting_sno': sno, # from first column
|
2026-06-14 13:57:32 +00:00
|
|
|
|
'results': results,
|
|
|
|
|
|
'next_opponent': next_opponent,
|
|
|
|
|
|
'next_color': next_color,
|
|
|
|
|
|
'tb': tb_values,
|
2026-06-14 20:29:27 +00:00
|
|
|
|
})
|
2026-06-14 13:57:32 +00:00
|
|
|
|
|
|
|
|
|
|
if players:
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
return players
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 20:29:27 +00:00
|
|
|
|
# ── Round pairings (art=2 compact format) ─────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def parse_game_row(texts: List[str], board: int) -> Optional[Dict]:
|
|
|
|
|
|
"""Parse one game row from art=2 page.
|
|
|
|
|
|
|
|
|
|
|
|
Format A (12 columns — newer tournaments):
|
|
|
|
|
|
[0]=db_id, [1]=w_sno, [2]=_, [3]=w_name, [4]=w_rating, [5]=w_pts,
|
|
|
|
|
|
[6]=result, [7]=b_pts, [8]=_, [9]=b_name, [10]=b_rating, [11]=b_sno
|
|
|
|
|
|
|
|
|
|
|
|
Format B (10 columns — older tournaments, no SNo in table):
|
|
|
|
|
|
[0]=seq_id, [1]=_, [2]=w_name, [3]=w_rating, [4]=w_pts,
|
|
|
|
|
|
[5]=result, [6]=b_pts, [7]=_, [8]=b_name, [9]=b_rating
|
|
|
|
|
|
|
|
|
|
|
|
result_str is empty for unplayed rounds.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if len(texts) < 10:
|
|
|
|
|
|
return None
|
|
|
|
|
|
if not texts[0].isdigit():
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
fmt_b = (len(texts) <= 11) # Older format without SNo columns or empty last cols
|
|
|
|
|
|
|
|
|
|
|
|
if fmt_b:
|
|
|
|
|
|
result_str = texts[5]
|
|
|
|
|
|
w_sno = 0 # Unknown — matched by name in fetch_tournament
|
|
|
|
|
|
b_sno = 0
|
|
|
|
|
|
w_name_idx, w_rating_idx, w_pts_idx = 2, 3, 4
|
|
|
|
|
|
b_pts_idx, b_name_idx, b_rating_idx = 6, 8, 9
|
|
|
|
|
|
else:
|
|
|
|
|
|
result_str = texts[6]
|
|
|
|
|
|
w_sno = int(texts[1]) if texts[1].isdigit() else 0
|
|
|
|
|
|
try:
|
|
|
|
|
|
b_sno = int(texts[11]) if texts[11].strip().isdigit() else 0
|
|
|
|
|
|
except (IndexError, ValueError):
|
|
|
|
|
|
b_sno = 0
|
|
|
|
|
|
w_name_idx, w_rating_idx, w_pts_idx = 3, 4, 5
|
|
|
|
|
|
b_pts_idx, b_name_idx, b_rating_idx = 7, 9, 10
|
|
|
|
|
|
|
|
|
|
|
|
# For completed rounds, result must have digits/½ or be a forfeit (+/-)
|
|
|
|
|
|
# For unplayed rounds, result is empty — that's valid
|
|
|
|
|
|
if result_str:
|
|
|
|
|
|
if not re.search(r'[\d½]', result_str) and '+ -' not in result_str and '- +' not in result_str:
|
|
|
|
|
|
return None
|
|
|
|
|
|
if not re.search(r'[-:]', result_str) and '+ -' not in result_str and '- +' not in result_str:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
w_name = texts[w_name_idx]
|
|
|
|
|
|
w_rating = int(texts[w_rating_idx]) if texts[w_rating_idx].isdigit() else 0
|
|
|
|
|
|
w_pts = float(texts[w_pts_idx].replace(',', '.').replace('½', '.5'))
|
|
|
|
|
|
b_pts = float(texts[b_pts_idx].replace(',', '.').replace('½', '.5'))
|
|
|
|
|
|
b_name = texts[b_name_idx]
|
|
|
|
|
|
b_rating = int(texts[b_rating_idx]) if texts[b_rating_idx].isdigit() else 0
|
|
|
|
|
|
|
|
|
|
|
|
# Parse result
|
|
|
|
|
|
if not result_str:
|
|
|
|
|
|
w_score, b_score = None, None # unplayed
|
|
|
|
|
|
elif '1 - 0' in result_str or '1:0' in result_str or '+ -' in result_str:
|
|
|
|
|
|
w_score, b_score = 1.0, 0.0
|
|
|
|
|
|
elif '0 - 1' in result_str or '0:1' in result_str or '- +' in result_str:
|
|
|
|
|
|
w_score, b_score = 0.0, 1.0
|
|
|
|
|
|
else:
|
|
|
|
|
|
w_score, b_score = 0.5, 0.5
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
'board': board,
|
|
|
|
|
|
'white_sno': w_sno, 'white_name': w_name, 'white_rating': w_rating,
|
|
|
|
|
|
'white_pts': w_pts, 'white_score': w_score,
|
|
|
|
|
|
'black_sno': b_sno, 'black_name': b_name, 'black_rating': b_rating,
|
|
|
|
|
|
'black_pts': b_pts, 'black_score': b_score,
|
|
|
|
|
|
'result': result_str.strip() if result_str else '',
|
|
|
|
|
|
}
|
|
|
|
|
|
except (ValueError, IndexError):
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_round_pairings(html: str, round_num: int) -> List[Dict]:
|
|
|
|
|
|
"""Parse pairings/results from art=2&rd=N page.
|
|
|
|
|
|
|
|
|
|
|
|
Board number is derived from row position in the table.
|
2026-06-14 13:57:32 +00:00
|
|
|
|
"""
|
|
|
|
|
|
soup = BeautifulSoup(html, 'html.parser')
|
2026-06-14 20:29:27 +00:00
|
|
|
|
games = []
|
2026-06-14 13:57:32 +00:00
|
|
|
|
|
|
|
|
|
|
tables = soup.find_all('table')
|
|
|
|
|
|
for table in tables:
|
|
|
|
|
|
rows = table.find_all('tr')
|
2026-06-14 20:29:27 +00:00
|
|
|
|
board = 0
|
2026-06-14 13:57:32 +00:00
|
|
|
|
for row in rows:
|
|
|
|
|
|
cells = row.find_all('td')
|
|
|
|
|
|
texts = [c.get_text(strip=True) for c in cells]
|
2026-06-14 20:29:27 +00:00
|
|
|
|
if len(texts) >= 10 and texts[0].isdigit():
|
|
|
|
|
|
board += 1
|
|
|
|
|
|
game = parse_game_row(texts, board)
|
|
|
|
|
|
if game:
|
|
|
|
|
|
game['round'] = round_num
|
|
|
|
|
|
games.append(game)
|
|
|
|
|
|
|
|
|
|
|
|
if games:
|
2026-06-14 13:57:32 +00:00
|
|
|
|
break
|
|
|
|
|
|
|
2026-06-14 20:29:27 +00:00
|
|
|
|
return games
|
|
|
|
|
|
|
2026-06-14 13:57:32 +00:00
|
|
|
|
|
2026-06-14 20:29:27 +00:00
|
|
|
|
# ── Tournament meta ───────────────────────────────────────────
|
2026-06-14 13:57:32 +00:00
|
|
|
|
|
|
|
|
|
|
def extract_tournament_meta(html: str) -> dict:
|
2026-06-14 20:29:27 +00:00
|
|
|
|
"""Extract name, num_rounds, current_round."""
|
2026-06-14 13:57:32 +00:00
|
|
|
|
soup = BeautifulSoup(html, 'html.parser')
|
2026-06-14 20:29:27 +00:00
|
|
|
|
info = {'name': '', 'num_rounds': 9, 'current_round': 1}
|
2026-06-14 13:57:32 +00:00
|
|
|
|
|
|
|
|
|
|
h2 = soup.find('h2')
|
|
|
|
|
|
if h2:
|
|
|
|
|
|
info['name'] = h2.get_text(strip=True)
|
|
|
|
|
|
|
2026-06-14 20:29:27 +00:00
|
|
|
|
text = soup.get_text()
|
2026-06-14 13:57:32 +00:00
|
|
|
|
|
2026-06-14 20:29:27 +00:00
|
|
|
|
# Number of rounds
|
|
|
|
|
|
m = re.search(r'Number of rounds\s*(\d+)', text)
|
2026-06-14 13:57:32 +00:00
|
|
|
|
if m:
|
2026-06-14 20:29:27 +00:00
|
|
|
|
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
|
2026-06-14 13:57:32 +00:00
|
|
|
|
|
|
|
|
|
|
return info
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 20:29:27 +00:00
|
|
|
|
# ── Full tournament fetch ─────────────────────────────────────
|
2026-06-14 13:57:32 +00:00
|
|
|
|
|
|
|
|
|
|
def fetch_tournament(url: str) -> dict:
|
2026-06-14 20:29:27 +00:00
|
|
|
|
"""Fetch full tournament data from any chess-results.com URL.
|
|
|
|
|
|
|
|
|
|
|
|
Detects current state automatically (any number of completed rounds).
|
2026-06-14 13:57:32 +00:00
|
|
|
|
"""
|
2026-06-14 20:29:27 +00:00
|
|
|
|
# Normalize URL to base
|
2026-06-14 13:57:32 +00:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-06-14 20:29:27 +00:00
|
|
|
|
# Fetch standings
|
2026-06-14 13:57:32 +00:00
|
|
|
|
standings_url = base_url + '&art=4&turdet=YES'
|
|
|
|
|
|
try:
|
|
|
|
|
|
html = fetch_url(standings_url)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
raise RuntimeError(f'Не удалось загрузить турнир: {e}')
|
|
|
|
|
|
|
|
|
|
|
|
meta = extract_tournament_meta(html)
|
2026-06-14 20:29:27 +00:00
|
|
|
|
standings = parse_standings(html)
|
2026-06-14 13:57:32 +00:00
|
|
|
|
|
2026-06-14 20:29:27 +00:00
|
|
|
|
if not standings:
|
|
|
|
|
|
raise RuntimeError('Не удалось распарсить таблицу положения')
|
2026-06-14 13:57:32 +00:00
|
|
|
|
|
2026-06-14 20:29:27 +00:00
|
|
|
|
# Completed rounds: use page metadata ("Положение после тура N")
|
|
|
|
|
|
current_round = meta['current_round']
|
|
|
|
|
|
# Sanity check against actual results
|
|
|
|
|
|
max_results = max(len(s['results']) for s in standings)
|
|
|
|
|
|
if current_round < 2 and max_results > current_round:
|
|
|
|
|
|
current_round = max_results
|
|
|
|
|
|
elif max_results < current_round:
|
|
|
|
|
|
current_round = max_results
|
2026-06-14 13:57:32 +00:00
|
|
|
|
|
2026-06-14 20:29:27 +00:00
|
|
|
|
# Starting list for ratings
|
2026-06-14 13:57:32 +00:00
|
|
|
|
try:
|
2026-06-14 20:29:27 +00:00
|
|
|
|
start_html = fetch_url(base_url + '&art=5&turdet=YES')
|
|
|
|
|
|
start_players = parse_start_list(start_html)
|
2026-06-14 13:57:32 +00:00
|
|
|
|
except Exception:
|
2026-06-14 20:29:27 +00:00
|
|
|
|
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
|
2026-06-14 13:57:32 +00:00
|
|
|
|
|
|
|
|
|
|
for s in standings:
|
2026-06-14 20:29:27 +00:00
|
|
|
|
sname = _normalize_name(s['name'])
|
|
|
|
|
|
# Try exact match first
|
|
|
|
|
|
real_sno = name_to_sno.get(sname)
|
|
|
|
|
|
if real_sno is None:
|
|
|
|
|
|
# Fuzzy: find best partial match (first+last name overlap)
|
|
|
|
|
|
sname_parts = set(sname.split())
|
|
|
|
|
|
best_sno = None
|
|
|
|
|
|
best_overlap = 0
|
|
|
|
|
|
for sl_name, sl_sno in name_to_sno.items():
|
|
|
|
|
|
sl_parts = set(sl_name.split())
|
|
|
|
|
|
overlap = len(sname_parts & sl_parts)
|
|
|
|
|
|
if overlap > best_overlap:
|
|
|
|
|
|
best_overlap = overlap
|
|
|
|
|
|
best_sno = sl_sno
|
|
|
|
|
|
real_sno = best_sno
|
|
|
|
|
|
|
|
|
|
|
|
s['starting_sno'] = real_sno if real_sno else s.get('starting_sno', s['rank'])
|
|
|
|
|
|
|
|
|
|
|
|
# Get real rating from start list
|
|
|
|
|
|
if real_sno and real_sno in start_players:
|
|
|
|
|
|
s['rating'] = start_players[real_sno].get('rating', s.get('rating', 0))
|
|
|
|
|
|
elif 'rating' not in s:
|
2026-06-14 13:57:32 +00:00
|
|
|
|
s['rating'] = 0
|
2026-06-14 20:29:27 +00:00
|
|
|
|
|
|
|
|
|
|
# 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:
|
|
|
|
|
|
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)
|
2026-06-14 13:57:32 +00:00
|
|
|
|
|
2026-06-15 17:43:34 +00:00
|
|
|
|
for s in standings:
|
|
|
|
|
|
s['points'] = sum(r.get('score', 0) for r in s.get('results', []))
|
|
|
|
|
|
|
2026-06-14 13:57:32 +00:00
|
|
|
|
return {
|
|
|
|
|
|
'name': meta['name'],
|
2026-06-14 20:29:27 +00:00
|
|
|
|
'num_rounds': meta['num_rounds'],
|
2026-06-14 13:57:32 +00:00
|
|
|
|
'current_round': current_round,
|
2026-06-14 20:29:27 +00:00
|
|
|
|
'players': start_players,
|
2026-06-14 13:57:32 +00:00
|
|
|
|
'standings': standings,
|
|
|
|
|
|
}
|
2026-06-14 20:29:27 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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]
|
2026-06-19 14:51:15 +00:00
|
|
|
|
if len(texts) >= 4 and texts[0].isdigit() and any(_is_player_name(t) for t in texts):
|
2026-06-14 20:29:27 +00:00
|
|
|
|
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):
|
2026-06-19 14:51:15 +00:00
|
|
|
|
if _is_player_name(t) and not name:
|
2026-06-14 20:29:27 +00:00
|
|
|
|
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
|