diff --git a/bots/client_bot.py b/bots/client_bot.py index c18584d..351284a 100644 --- a/bots/client_bot.py +++ b/bots/client_bot.py @@ -104,12 +104,15 @@ def _pad_col(s: str, width: int) -> str: return s + ' ' * (width - cur) -def format_pairings(pairings_data: dict, tournament_name: str, lang: str = '') -> str: +def format_pairings(pairings_data: dict, tournament_name: str, lang: str = '', + player_name: str = '', player_sno: int = 0) -> str: locale = _get_locale(lang) pairings = pairings_data['pairings'] rnd = pairings_data['round'] - w_board, w_name, w_rating, w_pts, w_tb1 = 2, 17, 4, 4, 5 + # If highlighting a player, reduce name width to compensate for ✅ emoji (visually 2 chars wide) + w_name = 15 if player_sno else 17 + w_board, w_rating, w_pts, w_tb1 = 2, 4, 4, 5 border = '+'.join('-' * w for w in [w_board, w_name, w_rating, w_pts, w_tb1]) header = (f'{_pad_col("#", w_board)}|{_pad_col(locale["player"], w_name)}|' @@ -121,23 +124,62 @@ def format_pairings(pairings_data: dict, tournament_name: str, lang: str = '') - footer = locale['footer_bbp'] if source == 'bbp_pairings_fide_2025' else \ locale['footer_fallback'] if source == 'swiss_algorithm_simplified' else '' + safe_tour = _md_escape(tournament_name) + title = f'📋 *{safe_tour}*\n*{locale["title"].format(rnd=rnd)}*' + return { - 'title': locale['title'].format(rnd=rnd), + 'title': title, 'rows': _build_table_rows(pairings, w_board, w_name, w_rating, w_pts, w_tb1, border, - locale['bye']), + locale['bye'], player_name, player_sno), 'table_header': table_header, 'footer': footer, } def _build_table_rows(pairings: list, w_board: int, w_name: int, w_rating: int, - w_pts: int, w_tb1: int, border: str, bye_label: str = '— BYE —') -> list: + w_pts: int, w_tb1: int, border: str, bye_label: str = '— BYE —', + player_name: str = '', player_sno: int = 0) -> list: def _rating_str(p): return str(p.rating) if p.rating else '' def _tb1_str(p): return f"{p.tb[0]:.1f}" if p.tb else '' + # Find the board where the player plays. + # Primary: match by SNo (reliable across Latin/Cyrillic name variations). + # Fallback: match by normalized name. + def _n(s: str) -> str: + return s.replace(',', '').replace(' ', ' ').lower() + + player_board = 0 + board_idx = 1 + player_norm = _n(player_name) if player_name else '' + for pairing in pairings: + if len(pairing) != 3: + continue + p1, p2, _color = pairing + if player_sno and (p1.sno == player_sno or p2.sno == player_sno): + player_board = board_idx + player_name = p1.name if p1.sno == player_sno else p2.name + break + if player_norm and (player_norm == _n(p1.name) or player_norm == _n(p2.name)): + player_board = board_idx + player_name = p1.name if player_norm == _n(p1.name) else p2.name + break + board_idx += 1 + + # If we have a player, show ±5 boards around them + if player_board > 0: + lo = max(1, player_board - 5) + hi = player_board + 5 + else: + lo, hi = 1, 999999 + + def _name_cell(name: str) -> str: + if player_name and name == player_name: + return f'✅ {name}' + return name + rows = [] board = 1 for pi, pairing in enumerate(pairings): @@ -145,10 +187,16 @@ def _build_table_rows(pairings: list, w_board: int, w_name: int, w_rating: int, continue p1, p2, color = pairing + if board < lo: + board += 1 + continue + if board > hi: + break + if color == 'bye': rows.append( f'{_pad_col(str(board), w_board)}|' - f'{_pad_col(p1.name, w_name)}|' + f'{_pad_col(_name_cell(p1.name), w_name)}|' f'{_pad_col(_rating_str(p1), w_rating)}|' f'{_pad_col(f"{p1.points:.1f}", w_pts)}|' f'{_pad_col(_tb1_str(p1), w_tb1)}' @@ -161,7 +209,7 @@ def _build_table_rows(pairings: list, w_board: int, w_name: int, w_rating: int, f'{_pad_col("", w_tb1)}' ) board += 1 - if pi + 1 < len(pairings): + if pi + 1 < len(pairings) and board <= hi: rows.append(border) continue @@ -172,22 +220,21 @@ def _build_table_rows(pairings: list, w_board: int, w_name: int, w_rating: int, rows.append( f'{_pad_col(str(board), w_board)}|' - f'{_pad_col(white.name, w_name)}|' + f'{_pad_col(_name_cell(white.name), w_name)}|' f'{_pad_col(_rating_str(white), w_rating)}|' f'{_pad_col(f"{white.points:.1f}", w_pts)}|' f'{_pad_col(_tb1_str(white), w_tb1)}' ) rows.append( f'{_pad_col("", w_board)}|' - f'{_pad_col(black.name, w_name)}|' + f'{_pad_col(_name_cell(black.name), w_name)}|' f'{_pad_col(_rating_str(black), w_rating)}|' f'{_pad_col(f"{black.points:.1f}", w_pts)}|' f'{_pad_col(_tb1_str(black), w_tb1)}' ) board += 1 - # Separator between pairs - if pi + 1 < len(pairings): + if pi + 1 < len(pairings) and board <= hi: rows.append(border) return rows @@ -262,9 +309,10 @@ async def my_players(update: Update, context: ContextTypes.DEFAULT_TYPE): for s in subs: safe_name = _md_escape(s['player_name']) safe_tour = _md_escape(s['tournament_name'] or '—') + last_pts_str = _md_escape(f'{s["last_points"]:.1f}') lines.append( f' {s["id"]}\\. {safe_name} \({s["rating"]}, {s["fed"]}\) ' - f'— {safe_tour} — {s["last_points"]:.1f} очк\\.') + f'— {safe_tour} — {last_pts_str} очк\\.') lines.append('\n/removeplayer <номер> — удалить подписку') await update.message.reply_text('\n'.join(lines), parse_mode=ParseMode.MARKDOWN_V2) @@ -315,19 +363,47 @@ async def _lookup_and_confirm(update: Update, context, fide_id: int): safe_name = _md_escape(player['name']) title_str = f', {player["title"]}' if player.get('title') else '' - - msg = ( + await update.message.reply_text( f'✅ *{safe_name}*{title_str}\n' f'{"🇫 " if player["fed"] else ""}{player["fed"]} \\| Рейтинг: {player["rating"]}\n\n' - 'Теперь отправь ссылку на турнир chess\\-results\\.com, ' - 'где играет этот игрок\.\n' - '/cancel — отмена' - ) - context.user_data['pending_fide'] = { - 'player': player, - 'lang': lang_code, - } - await update.message.reply_text(msg, parse_mode=ParseMode.MARKDOWN_V2) + '🔍 Ищу турниры на chess\\-results\\.com\\.\\.', + parse_mode=ParseMode.MARKDOWN_V2) + + # Run scan asynchronously (avoid blocking the event loop) + # Use canonical FIDE ID from profile page (may differ from URL-extracted ID) + from concurrent.futures import ThreadPoolExecutor + loop = asyncio.get_event_loop() + tournaments = await loop.run_in_executor( + None, tracker.scan_for_player, player['fide_id']) + + if tournaments: + # Auto-subscribe to all found tournaments + lines = [f'✅ *Найдено турниров: {len(tournaments)}*'] + for t in tournaments: + tracker.add_subscription( + user.id, player, t['url'], t['name'], t['sno'], + 0, 0.0, lang_code, t['start_date'], t['end_date']) + dates = '' + if t['start_date'] and t['end_date']: + dates = f' \({t["start_date"]} — {t["end_date"]}\)' + safe_tour = _md_escape(t['name']) + lines.append(f' • {safe_tour}{dates}') + lines.append('\nЯ буду присылать результаты и жеребьёвку\.\n' + '/myplayers \\- список подписок') + await update.message.reply_text('\n'.join(lines), + parse_mode=ParseMode.MARKDOWN_V2) + else: + # No tournaments found — fallback to manual URL + context.user_data['pending_fide'] = { + 'player': player, + 'lang': lang_code, + } + await update.message.reply_text( + '❌ Турниров не найдено автоматически\\.\n\n' + 'Отправь ссылку на турнир chess\\-results\\.com, ' + 'где играет этот игрок\\.\n' + '/cancel — отмена', + parse_mode=ParseMode.MARKDOWN_V2) def _apply_round_results(tournament, games, rd): @@ -442,7 +518,7 @@ async def _subscribe_to_tournament(update: Update, context, url: str, pending: d await msg.edit_text( f'✅ *{safe_name}* найден в турнире\\.\n' f'📅 *{safe_tour}*\n' - f'Ст\\.№ {p_sno}, {points:.1f} очк\\. — отслеживаю\!\n\n' + f'Ст\\.№ {p_sno}, {_md_escape(f"{points:.1f}")} очк\\. — отслеживаю\!\n\n' 'Я буду присылать результаты игрока и жеребьёвку следующих туров\.\n' '/myplayers — список подписок', parse_mode=ParseMode.MARKDOWN_V2) @@ -578,7 +654,20 @@ async def handle_url(update: Update, context: ContextTypes.DEFAULT_TYPE): return lang_code = user.language_code or '' - fmt = format_pairings(result, tournament['name'], lang_code) + + # If user has a subscription for this tournament, highlight their player + player_name = '' + try: + subs = tracker.get_user_subs(user.id) + for s in subs: + sub_tour = s.get('tournament_name', '') + if sub_tour and sub_tour == tournament.get('name', ''): + player_name = s.get('player_name', '') + break + except Exception: + pass + + fmt = format_pairings(result, tournament['name'], lang_code, player_name) chunks = _render_chunks(fmt) try: @@ -630,6 +719,12 @@ def main(): app.job_queue.run_repeating( tracker.check_all_subscriptions, interval=300, first=10) + app.job_queue.run_repeating( + tracker.rescan_new_tournaments, interval=3600, first=60) + app.job_queue.run_repeating( + tracker.warmup_cache, interval=21600, first=10) + + print('Client bot started', file=sys.stderr) print('Client bot started', file=sys.stderr) app.run_polling() diff --git a/bots/tracker.py b/bots/tracker.py index 477fb78..fbaaf8e 100644 --- a/bots/tracker.py +++ b/bots/tracker.py @@ -2,6 +2,7 @@ Player tracking: FIDE profile lookup, subscription management, background polling for new results and pairings. """ +import json import re import os import sys @@ -32,6 +33,12 @@ RESULT_LOCALE = { } +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: @@ -57,10 +64,31 @@ def _get_conn(): 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 @@ -107,6 +135,11 @@ def fetch_fide_player(fide_id: int) -> dict: # 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, @@ -118,17 +151,17 @@ def fetch_fide_player(fide_id: int) -> dict: 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): + 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, active) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 1) + 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)) + player_sno, last_results_count, last_points, start_date, end_date)) conn.commit() conn.close() @@ -264,6 +297,7 @@ def format_result(player_name: str, result: dict, opponent_name: str, 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' @@ -272,7 +306,383 @@ def format_result(player_name: str, result: dict, opponent_name: str, ) -# ═══ Background check ═══ +# ═══ 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 + + +# ═══ 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