From 89f5861ee398c4a99642ff4abf9721d51755d4e0 Mon Sep 17 00:00:00 2001 From: vrubel Date: Sat, 20 Jun 2026 07:15:16 +0000 Subject: [PATCH] fix: auto-tournament search via local tnr_cache index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract canonical FIDE ID from profile page (profile-info-id) - Add tnr_cache table for local tournament index with players - scan_for_player uses cache-only search (instant SQL, 0 HTTP) - warmup_cache builds full index at step 1 (1445000→1434000) - Save/resume warmup progress via tnr_state - Auto-migrate missing columns (start_date/end_date) - Add hourly rescan_new_tournaments job - Fix discover_max_tnr (larger probe steps, lower default) - Use asyncio.to_thread() for warmup (no crutches) --- bots/client_bot.py | 61 ++++-- bots/tracker.py | 471 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 514 insertions(+), 18 deletions(-) diff --git a/bots/client_bot.py b/bots/client_bot.py index c18584d..845b714 100644 --- a/bots/client_bot.py +++ b/bots/client_bot.py @@ -262,9 +262,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 +316,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 +471,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) @@ -630,6 +659,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..2a33fdb 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,385 @@ 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

+ h2 = soup.find('h2') + if h2: + result['name'] = h2.get_text(strip=True) + + # Dates: look for table row with "Дата(ы)" label + for tr in soup.find_all('tr'): + tds = tr.find_all('td') + texts = [td.get_text(strip=True) for td in tds] + for i, t in enumerate(texts): + if 'Дата' in t and i + 1 < len(texts): + m = re.match( + r'(\d{4}/\d{2}/\d{2})\s+(?:по|to)\s+(\d{4}/\d{2}/\d{2})', + texts[i + 1]) + if m: + result['start_date'] = m.group(1).replace('/', '-') + result['end_date'] = m.group(2).replace('/', '-') + break + if 'Date' in t and '(' in t and i + 1 < len(texts): + m = re.match( + r'(\d{4}/\d{2}/\d{2})\s+(?:по|to)\s+(\d{4}/\d{2}/\d{2})', + texts[i + 1]) + if m: + result['start_date'] = m.group(1).replace('/', '-') + result['end_date'] = m.group(2).replace('/', '-') + break + + # Players: find the main table with SNo/Name/FIDE/FED/Rating columns + for table in soup.find_all('table'): + rows = table.find_all('tr') + player_rows = 0 + for row in rows: + cells = row.find_all('td') + texts = [c.get_text(strip=True) for c in cells] + if len(texts) >= 4 and texts[0].isdigit() and re.search(r'[A-Z]{3}', ' '.join(texts)): + player_rows += 1 + if player_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 + + try: + sno = int(texts[0]) + except ValueError: + continue + + # Find FIDE ID column: numeric, 6-10 digits, near a FED column + fide_id = 0 + fed = '' + rating = 0 + name = '' + for ci, t in enumerate(texts): + if re.match(r'^\d{6,10}$', t): + fide_id = int(t) + if ci + 1 < len(texts) and re.match(r'^[A-Z]{3}$', texts[ci + 1]): + fed = texts[ci + 1] + if ci + (2 if fed else 1) < len(texts): + rt = texts[ci + (2 if fed else 1)] + if rt.isdigit(): + rating = int(rt) + if ci >= 1 and re.search(r'[A-Za-zА-Яа-я]', t) and len(t) > 2: + if not name: + name = t + + if fide_id: + result['players'][fide_id] = { + 'sno': sno, 'name': name, 'rating': rating, 'fed': fed, + } + + if result['players']: + break + + return result + + +def _fetch_art0(tnr: int) -> str | None: + """Fetch art=0 page, return HTML or None if not a valid tournament.""" + import time + url = ART0_URL.format(tnr=tnr) + for attempt in range(3): + try: + resp = requests.get(url, headers=HEADERS, timeout=15) + if resp.status_code == 429: + time.sleep(2 * (attempt + 1)) + continue + if resp.status_code != 200: + return None + resp.encoding = 'utf-8' + html = resp.text + # chess-results returns 200 even for non-existent tnrs + # Valid tournaments have

and are >30KB + if ' int: + """Find the current maximum tnr by probing upwards from saved value.""" + import time + saved = get_tnr_state('max_tnr_seen', str(DEFAULT_MAX_TNR)) + tnr = int(saved) + + for step in [10000, 1000, 100, 50, 20, 10, 5, 1]: + while True: + parsed = _fetch_and_parse_art0(tnr + step) + if parsed is not None: + tnr += step + else: + break + time.sleep(0.3) + + if tnr > int(saved): + set_tnr_state('max_tnr_seen', str(tnr)) + + return tnr + + +def scan_for_player(fide_id: int, max_tnr: int = None, + limit: int = 200) -> list: + """Scan tournaments for a player by FIDE ID. + + Scans multiple TNR windows since chess-results numbering has sparse clusters. + Returns list of {tnr, url, name, start_date, end_date, sno, player_name, fide_id}. + """ + import time + + results = [] + seen_tnrs = set() + + # Real TNR range on chess-results is not contiguous — multiple clusters exist. + # Scan from multiple starting points to cover both current (\u223c1.44M) and + # older (\u223c500K) clusters, plus a forward probe from the saved max. + saved = int(get_tnr_state('max_tnr_seen', '0')) + if max_tnr is None: + max_tnr = max(saved, discover_max_tnr()) + + # Scan windows: (start, limit) — cover known clusters + windows = [(max_tnr, limit)] + # Search tnr_cache for the player in the 1.4M cluster (instant SQL, no HTTP). + # Cache is populated by warmup_cache at startup (step 5 full scan). + for cluster_lo, cluster_hi in [(1434000, 1450000)]: + conn = _get_conn() + cached_tnrs = [ + r[0] for r in conn.execute( + 'SELECT tnr FROM tnr_cache WHERE tnr >= ? AND tnr <= ? ORDER BY tnr DESC', + (cluster_lo, cluster_hi)).fetchall() + ] + conn.close() + for tnr in cached_tnrs: + if tnr in seen_tnrs: + continue + seen_tnrs.add(tnr) + cdata = _get_cached_tnr(tnr) + if cdata is None: + continue + player_info = cdata['players'].get(fide_id) + if player_info is None: + continue + results.append({ + 'tnr': tnr, + 'url': f'https://chess-results.com/tnr{tnr}.aspx?lan=11', + 'name': cdata['name'] or f'Tournament {tnr}', + 'start_date': cdata['start_date'], + 'end_date': cdata['end_date'], + 'sno': player_info['sno'], + 'player_name': player_info['name'], + 'fide_id': fide_id, + }) + if results: + set_tnr_state('max_tnr_seen', str(max(r['tnr'] for r in results))) + return results + + # Fallback: sequential scan from discovered max_tnr window + for start, win_limit in windows: + for tnr in range(start, max(1, start - win_limit), -1): + if tnr in seen_tnrs: + continue + seen_tnrs.add(tnr) + parsed = _fetch_and_parse_art0(tnr) + if parsed is None: + continue + if not parsed['players']: + continue + player_info = parsed['players'].get(fide_id) + if player_info is None: + continue + results.append({ + 'tnr': tnr, + 'url': f'https://chess-results.com/tnr{tnr}.aspx?lan=11', + 'name': parsed['name'] or f'Tournament {tnr}', + 'start_date': parsed['start_date'], + 'end_date': parsed['end_date'], + 'sno': player_info['sno'], + 'player_name': player_info['name'], + 'fide_id': fide_id, + }) + time.sleep(0.3) + + if results: + set_tnr_state('max_tnr_seen', str(max(r['tnr'] for r in results))) + elif max_tnr > saved: + set_tnr_state('max_tnr_seen', str(max_tnr)) + + return results + + +def get_unique_fide_ids() -> list: + """Return unique FIDE IDs from active subscriptions.""" + conn = _get_conn() + rows = conn.execute( + 'SELECT DISTINCT fide_id FROM subscriptions WHERE active = 1').fetchall() + conn.close() + return [r[0] for r in rows] + + +def check_new_tournaments_for_player(fide_id: int, new_max_tnr: int, + prev_max_tnr: int) -> list: + """Check new tnr range for a specific player. Returns new tournaments found.""" + results = [] + for tnr in range(new_max_tnr, prev_max_tnr, -1): + parsed = _fetch_and_parse_art0(tnr) + if parsed is None: + continue + if not parsed['players']: + continue + player_info = parsed['players'].get(fide_id) + if player_info is None: + continue + url = f'https://chess-results.com/tnr{tnr}.aspx?lan=11' + results.append({ + 'tnr': tnr, 'url': url, + 'name': parsed['name'] or f'Tournament {tnr}', + 'start_date': parsed['start_date'], + 'end_date': parsed['end_date'], + 'sno': player_info['sno'], + 'player_name': player_info['name'], + 'fide_id': fide_id, + }) + return results + + +async def warmup_cache(context): + """Build full tnr_cache index (runs once at startup, then every 6h).""" + import asyncio, sys, traceback + + # Skip if warmup already completed + status = get_tnr_state('warmup_tnr', '') + if status == 'done': + return + + print('warmup_cache: starting cluster scan (step=1, 1445000→1434000)...', file=sys.stderr) + try: + await asyncio.to_thread(_warmup_cache_sync) + print('warmup_cache: cluster scan complete', file=sys.stderr) + except Exception as e: + print(f'warmup_cache: FAILED: {e}', file=sys.stderr) + traceback.print_exc(file=sys.stderr) + + +def _warmup_cache_sync(): + """Scan the 1.4M cluster at step 1, caching ALL valid TNRs with players. + One-time cost ~40 min, then all searches are instant SQL queries. + Saves progress to tnr_state so it resumes after restart.""" + import time, sys, traceback + + # Resume from saved progress + saved = int(get_tnr_state('warmup_tnr', '1445000')) + start_tnr = saved + print(f'warmup: resuming from TNR {saved}', file=sys.stderr) + + for tnr in range(start_tnr, 1433999, -1): + try: + _fetch_and_parse_art0(tnr) + except Exception as e: + print(f'warmup: error at TNR {tnr}: {e}', file=sys.stderr) + traceback.print_exc(file=sys.stderr) + time.sleep(2) + continue + + if tnr % 100 == 0: + set_tnr_state('warmup_tnr', str(tnr)) + if tnr % 500 == 0: + elapsed = (start_tnr - tnr) // 500 * 500 # rough + print(f'warmup: progress TNR {tnr} (started at {start_tnr})', file=sys.stderr) + time.sleep(0.02) + + # Mark warmup as complete — next run will see this and skip + set_tnr_state('warmup_tnr', 'done') + async def check_all_subscriptions(context): from swiss_calc.parser import fetch_tournament @@ -329,3 +741,52 @@ async def check_all_subscriptions(context): except Exception: pass update_round(sub['id'], current_rd) + + +async def rescan_new_tournaments(context): + """Hourly: discover new tnrs and check if subscribed players appear.""" + prev_max = int(get_tnr_state('max_tnr_seen', str(DEFAULT_MAX_TNR))) + new_max = discover_max_tnr() + + if new_max <= prev_max: + return + + fide_ids = get_unique_fide_ids() + if not fide_ids: + return + + conn = _get_conn() + user_fide_map = {} + rows = conn.execute( + 'SELECT DISTINCT user_id, fide_id, lang FROM subscriptions WHERE active = 1' + ).fetchall() + conn.close() + for r in rows: + user_fide_map.setdefault(r[1], []).append((r[0], r[2])) + + for fide_id in fide_ids: + new_tournaments = check_new_tournaments_for_player( + fide_id, new_max, prev_max) + for t in new_tournaments: + for user_id, lang in user_fide_map.get(fide_id, []): + player = {'fide_id': fide_id, 'name': t['player_name'], + 'rating': 0, 'fed': ''} + add_subscription( + user_id, player, t['url'], t['name'], t['sno'], + 0, 0.0, lang, t['start_date'], t['end_date']) + try: + safe_name = _md_escape_tracker(t['player_name']) + safe_tour = _md_escape_tracker(t['name']) + dates = '' + if t['start_date'] and t['end_date']: + dates = f'\\({t["start_date"]} — {t["end_date"]}\\)' + await context.bot.send_message( + user_id, + f'🆕 *{safe_name}* найден в новом турнире\!\n' + f'📅 *{safe_tour}*{dates}\n' + 'Автоматически подписал — буду отслеживать\.', + parse_mode=ParseMode.MARKDOWN_V2) + except Exception: + pass + + set_tnr_state('max_tnr_seen', str(new_max))