diff --git a/bots/client_bot.py b/bots/client_bot.py index 351284a..c18584d 100644 --- a/bots/client_bot.py +++ b/bots/client_bot.py @@ -104,15 +104,12 @@ def _pad_col(s: str, width: int) -> str: return s + ' ' * (width - cur) -def format_pairings(pairings_data: dict, tournament_name: str, lang: str = '', - player_name: str = '', player_sno: int = 0) -> str: +def format_pairings(pairings_data: dict, tournament_name: str, lang: str = '') -> str: locale = _get_locale(lang) pairings = pairings_data['pairings'] rnd = pairings_data['round'] - # 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 + w_board, w_name, w_rating, w_pts, w_tb1 = 2, 17, 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)}|' @@ -124,62 +121,23 @@ 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': title, + 'title': locale['title'].format(rnd=rnd), 'rows': _build_table_rows(pairings, w_board, w_name, w_rating, w_pts, w_tb1, border, - locale['bye'], player_name, player_sno), + locale['bye']), '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 —', - player_name: str = '', player_sno: int = 0) -> list: + w_pts: int, w_tb1: int, border: str, bye_label: str = '— BYE —') -> 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): @@ -187,16 +145,10 @@ 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(_name_cell(p1.name), w_name)}|' + f'{_pad_col(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)}' @@ -209,7 +161,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) and board <= hi: + if pi + 1 < len(pairings): rows.append(border) continue @@ -220,21 +172,22 @@ 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(_name_cell(white.name), w_name)}|' + f'{_pad_col(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(_name_cell(black.name), w_name)}|' + f'{_pad_col(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 - if pi + 1 < len(pairings) and board <= hi: + # Separator between pairs + if pi + 1 < len(pairings): rows.append(border) return rows @@ -309,10 +262,9 @@ 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} — {last_pts_str} очк\\.') + f'— {safe_tour} — {s["last_points"]:.1f} очк\\.') lines.append('\n/removeplayer <номер> — удалить подписку') await update.message.reply_text('\n'.join(lines), parse_mode=ParseMode.MARKDOWN_V2) @@ -363,47 +315,19 @@ 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 '' - await update.message.reply_text( + + msg = ( f'✅ *{safe_name}*{title_str}\n' f'{"🇫 " if player["fed"] else ""}{player["fed"]} \\| Рейтинг: {player["rating"]}\n\n' - '🔍 Ищу турниры на 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) + 'Теперь отправь ссылку на турнир 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) def _apply_round_results(tournament, games, rd): @@ -518,7 +442,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}, {_md_escape(f"{points:.1f}")} очк\\. — отслеживаю\!\n\n' + f'Ст\\.№ {p_sno}, {points:.1f} очк\\. — отслеживаю\!\n\n' 'Я буду присылать результаты игрока и жеребьёвку следующих туров\.\n' '/myplayers — список подписок', parse_mode=ParseMode.MARKDOWN_V2) @@ -654,20 +578,7 @@ async def handle_url(update: Update, context: ContextTypes.DEFAULT_TYPE): return lang_code = user.language_code or '' - - # 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) + fmt = format_pairings(result, tournament['name'], lang_code) chunks = _render_chunks(fmt) try: @@ -719,12 +630,6 @@ 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 fbaaf8e..477fb78 100644 --- a/bots/tracker.py +++ b/bots/tracker.py @@ -2,7 +2,6 @@ Player tracking: FIDE profile lookup, subscription management, background polling for new results and pairings. """ -import json import re import os import sys @@ -33,12 +32,6 @@ 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: @@ -64,31 +57,10 @@ 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 @@ -135,11 +107,6 @@ 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, @@ -151,17 +118,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, start_date: str = '', end_date: str = ''): + 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, start_date, end_date, active) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, 1) + 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, start_date, end_date)) + player_sno, last_results_count, last_points)) conn.commit() conn.close() @@ -297,7 +264,6 @@ 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' @@ -306,383 +272,7 @@ def format_result(player_name: str, result: dict, opponent_name: str, ) -# ═══ 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