""" Player tracking: FIDE profile lookup, subscription management, background polling for new results and pairings. """ import re import os import sys import sqlite3 import requests from pathlib import Path from datetime import date sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) DB_PATH = os.environ.get('STATS_DB', '/app/data/tournaments.db') FIDE_URL = 'https://ratings.fide.com/profile/{fide_id}' HEADERS = { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36' } RESULT_LOCALE = { 'ru': { 'win': 'победа', 'draw': 'ничья', 'loss': 'поражение', 'white': 'белыми', 'black': 'чёрными', 'points': 'Очков', 'round': 'тур', 'vs': 'против', }, 'en': { 'win': 'win', 'draw': 'draw', 'loss': 'loss', 'white': 'White', 'black': 'Black', 'points': 'Points', 'round': 'round', 'vs': 'vs', }, } def _get_conn(): p = Path(DB_PATH) try: p.parent.mkdir(parents=True, exist_ok=True) except PermissionError: fallback = Path(__file__).parent.parent / 'data' / 'tournaments.db' fallback.parent.mkdir(parents=True, exist_ok=True) p = fallback conn = sqlite3.connect(str(p)) conn.execute('PRAGMA journal_mode=WAL') conn.execute(''' CREATE TABLE IF NOT EXISTS subscriptions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, fide_id INTEGER NOT NULL, player_name TEXT NOT NULL, rating INTEGER DEFAULT 0, fed TEXT DEFAULT '', lang TEXT DEFAULT 'en', tournament_url TEXT NOT NULL, tournament_name TEXT DEFAULT '', player_sno INTEGER DEFAULT 0, last_results_count INTEGER DEFAULT 0, last_points REAL DEFAULT 0.0, last_round_done INTEGER DEFAULT 0, active INTEGER DEFAULT 1, UNIQUE(user_id, fide_id, tournament_url) ) ''') conn.commit() return conn # ═══ FIDE profile scraper ═══ def fetch_fide_player(fide_id: int) -> dict: url = FIDE_URL.format(fide_id=fide_id) resp = requests.get(url, headers=HEADERS, timeout=20) if resp.status_code != 200: raise RuntimeError(f'FIDE profile not found (HTTP {resp.status_code})') html = resp.text # Check for "No record found" if 'No record found' in html: raise RuntimeError(f'Player with FIDE ID {fide_id} not found') # Name:
2841
STANDARD rating = 0 m = re.search(r'
(\d{3,4})
\s*]*>\s*STANDARD', html) if m: rating = int(m.group(1)) # Title:
Grandmaster
title = '' m = re.search(r'(.*?)
', html) if m: title = m.group(1).strip() # Some players have multiple titles in adjacent— take only the first one title = re.sub(r'<[^>]+>', '', title).strip() return { 'fide_id': fide_id, 'name': name, 'rating': rating, 'fed': fed, 'title': title, } # ═══ Subscription CRUD ═══ def add_subscription(user_id: int, player: dict, tournament_url: str, tournament_name: str, player_sno: int, last_results_count: int, last_points: float, lang: str): conn = _get_conn() conn.execute(''' INSERT OR REPLACE INTO subscriptions (user_id, fide_id, player_name, rating, fed, lang, tournament_url, tournament_name, player_sno, last_results_count, last_points, last_round_done, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 1) ''', (user_id, player['fide_id'], player['name'], player['rating'], player.get('fed', ''), lang, tournament_url, tournament_name, player_sno, last_results_count, last_points)) conn.commit() conn.close() def get_user_subs(user_id: int) -> list: conn = _get_conn() rows = conn.execute( 'SELECT id, player_name, rating, fed, tournament_name, ' 'last_points, last_round_done, active FROM subscriptions ' 'WHERE user_id = ? AND active = 1 ORDER BY id', (user_id,)).fetchall() conn.close() return [{'id': r[0], 'player_name': r[1], 'rating': r[2], 'fed': r[3], 'tournament_name': r[4], 'last_points': r[5], 'last_round_done': r[6], 'active': r[7]} for r in rows] def get_active_subs() -> list: conn = _get_conn() rows = conn.execute( 'SELECT id, user_id, fide_id, player_name, rating, fed, lang, ' 'tournament_url, tournament_name, player_sno, last_results_count, ' 'last_points, last_round_done FROM subscriptions ' 'WHERE active = 1').fetchall() conn.close() return [dict(zip( ['id', 'user_id', 'fide_id', 'player_name', 'rating', 'fed', 'lang', 'tournament_url', 'tournament_name', 'player_sno', 'last_results_count', 'last_points', 'last_round_done'], r)) for r in rows] def remove_subscription(sub_id: int, user_id: int) -> bool: conn = _get_conn() cur = conn.execute( 'UPDATE subscriptions SET active = 0 WHERE id = ? AND user_id = ?', (sub_id, user_id)) conn.commit() deleted = cur.rowcount > 0 conn.close() return deleted def update_result(sub_id: int, results_count: int, points: float): conn = _get_conn() conn.execute( 'UPDATE subscriptions SET last_results_count = ?, last_points = ? ' 'WHERE id = ?', (results_count, points, sub_id)) conn.commit() conn.close() def update_round(sub_id: int, round_num: int): conn = _get_conn() conn.execute( 'UPDATE subscriptions SET last_round_done = ? WHERE id = ?', (round_num, sub_id)) conn.commit() conn.close() # ═══ Tournament player lookup ═══ def _normalize(s: str) -> str: return re.sub(r'\s+', ' ', s.strip()).lower() def find_player(tournament: dict, name: str, sno: int = 0) -> dict | None: standings = tournament.get('standings', []) # Try exact SNo match first if sno: for s in standings: if s.get('starting_sno') == sno: return s # Try exact name match (normalized) norm_target = _normalize(name) for s in standings: if _normalize(s['name']) == norm_target: return s # Fuzzy: split into parts, find best overlap target_parts = set(norm_target.split()) best, best_overlap = None, 0 for s in standings: s_parts = set(_normalize(s['name']).split()) overlap = len(target_parts & s_parts) if overlap > best_overlap: best_overlap = overlap best = s if best and best_overlap >= 1: return best return None def find_opponent_name(tournament: dict, opp_sno: int) -> str: if not opp_sno: return 'BYE' standings = tournament.get('standings', []) for s in standings: if s.get('starting_sno') == opp_sno: return s['name'] return f'#{opp_sno}' # ═══ Result formatting ═══ def _md_escape_tracker(text: str) -> str: escape_chars = r'_*[]()~`>#+-=|{}.!' result = [] for ch in text: if ch in escape_chars: result.append('\\' + ch) else: result.append(ch) return ''.join(result) def format_result(player_name: str, result: dict, opponent_name: str, points: float, lang: str) -> str: loc = RESULT_LOCALE.get(lang, RESULT_LOCALE['en']) score = result.get('score', 0.0) color = result.get('color', '') rd = result.get('round', '?') score_label = {1.0: loc['win'], 0.5: loc['draw'], 0.0: loc['loss']} score_desc = score_label.get(score, f'{score}') color_desc = loc['white'] if color == 'w' else loc['black'] safe_name = _md_escape_tracker(player_name) safe_opp = _md_escape_tracker(opponent_name) return ( f'♟ *{safe_name}* — {loc["round"]} {rd}\n' f'{score_desc} \({color_desc}\) {loc["vs"]} {safe_opp}\n' f'{loc["points"]}: {points:.1f}' ) # ═══ Background check ═══ async def check_all_subscriptions(context): from swiss_calc.parser import fetch_tournament from swiss_calc.swiss import calculate_next_round from bots.client_bot import format_pairings, _render_chunks from telegram.constants import ParseMode subs = get_active_subs() if not subs: return today_str = date.today().isoformat() for sub in subs: try: tournament = fetch_tournament(sub['tournament_url']) except Exception: continue player = find_player(tournament, sub['player_name'], sub['player_sno']) if player is None: continue results_count = len(player.get('results', [])) points = player.get('points', 0.0) # New individual result detected if results_count > sub['last_results_count']: new_r = player['results'][-1] opp_name = find_opponent_name(tournament, new_r.get('opponent', 0)) msg = format_result( player['name'], new_r, opp_name, points, sub['lang']) try: await context.bot.send_message( sub['user_id'], msg, parse_mode=ParseMode.MARKDOWN_V2) except Exception: pass update_result(sub['id'], results_count, points) # Round fully completed → calculate next round pairings current_rd = tournament.get('current_round', 0) if current_rd > sub['last_round_done'] and current_rd < tournament.get('num_rounds', 0): try: result = calculate_next_round(tournament) fmt = format_pairings(result, tournament.get('name', ''), sub['lang']) chunks = _render_chunks(fmt) for chunk in chunks: try: await context.bot.send_message( sub['user_id'], chunk, parse_mode=ParseMode.MARKDOWN_V2, disable_web_page_preview=True) except Exception: break except Exception: pass update_round(sub['id'], current_rd)