""" Player tracking: FIDE profile lookup, subscription management, background polling for new results and pairings. """ import json 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 _migrate_add_column(conn, table: str, column: str, col_def: str): cols = [r[1] for r in conn.execute(f'PRAGMA table_info({table})').fetchall()] if column not in cols: conn.execute(f'ALTER TABLE {table} ADD COLUMN {column} {col_def}') 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, start_date TEXT DEFAULT '', end_date TEXT DEFAULT '', active INTEGER DEFAULT 1, UNIQUE(user_id, fide_id, tournament_url) ) ''') conn.execute(''' CREATE TABLE IF NOT EXISTS tnr_state ( key TEXT PRIMARY KEY, value TEXT ) ''') conn.execute(''' CREATE TABLE IF NOT EXISTS tnr_cache ( tnr INTEGER PRIMARY KEY, name TEXT DEFAULT '', start_date TEXT DEFAULT '', end_date TEXT DEFAULT '', players_json TEXT DEFAULT '{}', scanned_at TEXT DEFAULT (datetime('now')) ) ''') # Migrate existing tables that may be missing columns added in later versions _migrate_add_column(conn, 'subscriptions', 'start_date', "TEXT DEFAULT ''") _migrate_add_column(conn, 'subscriptions', 'end_date', "TEXT DEFAULT ''") 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() # Canonical FIDE ID from profile page:
1503014
m = re.search(r'\s*(\d+)\s*
', html) if m: fide_id = int(m.group(1)) return { 'fide_id': fide_id, 'name': name, 'rating': rating, 'fed': fed, 'title': title, } # ═══ Subscription CRUD ═══ def add_player_watch(user_id: int, player: dict, lang: str): """Subscribe to a player with no active tournament yet. rescan_new_tournaments will auto-upgrade this to a real subscription when a tournament appears. """ conn = _get_conn() conn.execute(''' INSERT OR IGNORE 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 (?, ?, ?, ?, ?, ?, 'fide_only', '', 0, 0, 0.0, 0, 1) ''', (user_id, player['fide_id'], player['name'], player.get('rating', 0), player.get('fed', ''), lang)) conn.commit() conn.close() 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, start_date: str = '', end_date: 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, start_date, end_date, 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, start_date, end_date)) conn.commit() conn.close() def get_user_subs(user_id: int) -> list: """Return subscriptions grouped by player (fide_id).""" conn = _get_conn() rows = conn.execute( 'SELECT fide_id, player_name, rating, fed, tournament_name, ' 'last_points, last_round_done, tournament_url FROM subscriptions ' 'WHERE user_id = ? AND active = 1 ORDER BY fide_id, id', (user_id,)).fetchall() conn.close() players: dict = {} for r in rows: fide_id, pname, rating, fed, tname, pts, rd, url = r if fide_id not in players: players[fide_id] = { 'fide_id': fide_id, 'player_name': pname, 'rating': rating, 'fed': fed or '', 'tournaments': [], } if url == 'fide_only': players[fide_id]['tournaments'].append({ 'name': None, 'points': pts, 'round_done': rd, }) else: players[fide_id]['tournaments'].append({ 'name': tname or url, 'points': pts, 'round_done': rd, }) return list(players.values()) 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 remove_player_subscriptions(fide_id: int, user_id: int) -> bool: """Deactivate all subscriptions for a player (by FIDE ID).""" conn = _get_conn() cur = conn.execute( 'UPDATE subscriptions SET active = 0 WHERE fide_id = ? AND user_id = ?', (fide_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) safe_points = _md_escape_tracker(f'{points:.1f}') return ( f'♟ *{safe_name}* — {loc["round"]} {rd}\n' f'{score_desc} \({color_desc}\) {loc["vs"]} {safe_opp}\n' f'{loc["points"]}: {points:.1f}' ) # ═══ TNR state cache ═══ DEFAULT_MAX_TNR = 500000 def get_tnr_state(key: str, default: str = '') -> str: conn = _get_conn() row = conn.execute( 'SELECT value FROM tnr_state WHERE key = ?', (key,)).fetchone() conn.close() return row[0] if row else default def set_tnr_state(key: str, value: str): conn = _get_conn() conn.execute( 'INSERT OR REPLACE INTO tnr_state (key, value) VALUES (?, ?)', (key, value)) conn.commit() conn.close() # ═══ TNR cache (avoids re-fetching art=0 pages) ═══ def _get_cached_tnr(tnr: int) -> dict | None: conn = _get_conn() row = conn.execute( 'SELECT name, start_date, end_date, players_json FROM tnr_cache WHERE tnr = ?', (tnr,)).fetchone() conn.close() if row is None: return None players_raw = json.loads(row[3]) players = {int(k): v for k, v in players_raw.items()} return { 'name': row[0], 'start_date': row[1], 'end_date': row[2], 'players': players, } def _save_tnr_cache(tnr: int, data: dict): players_raw = {str(k): v for k, v in data.get('players', {}).items()} conn = _get_conn() conn.execute( 'INSERT OR REPLACE INTO tnr_cache (tnr, name, start_date, end_date, players_json) ' 'VALUES (?, ?, ?, ?, ?)', (tnr, data.get('name', ''), data.get('start_date', ''), data.get('end_date', ''), json.dumps(players_raw, ensure_ascii=False))) conn.commit() conn.close() def _fetch_and_parse_art0(tnr: int) -> dict | None: """Fetch art=0 page (or use cache), return parsed dict or None.""" cached = _get_cached_tnr(tnr) if cached is not None: return cached html = _fetch_art0(tnr) if html is None: return None parsed = _parse_art0_page(html) _save_tnr_cache(tnr, parsed) return parsed def _refetch_art0(tnr: int) -> dict | None: """Always fetch art=0 via HTTP, update cache. Returns parsed dict or None.""" html = _fetch_art0(tnr) if html is None: return None parsed = _parse_art0_page(html) _save_tnr_cache(tnr, parsed) return parsed # ═══ Tournament scanner (art=0 pages) ═══ ART0_URL = 'https://chess-results.com/tnr{tnr}.aspx?lan=11&art=0&turdet=YES' def _parse_art0_page(html: str) -> dict: """Parse art=0 page: extract tournament name, dates, and list of players.""" from bs4 import BeautifulSoup result = { 'name': '', 'start_date': '', 'end_date': '', 'players': {}, # fide_id -> {sno, name, rating, fed} } soup = BeautifulSoup(html, 'html.parser') # Tournament name: from