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

+ 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() + + # Phase 1: check tnr_cache via SQL (single query, instant). Return if found. + conn = _get_conn() + pattern = f'%"{fide_id}"%' + rows = conn.execute( + 'SELECT tnr, name, start_date, end_date, players_json FROM tnr_cache ' + 'WHERE tnr >= ? AND tnr <= ? AND players_json LIKE ? ' + 'ORDER BY tnr DESC', + (1434000, 1450000, pattern)).fetchall() + conn.close() + for row in rows: + tnr, tname, tstart, tend, players_json = row + players = {int(k): v for k, v in json.loads(players_json).items()} + player_info = 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': tname or f'Tournament {tnr}', + 'start_date': tstart, + 'end_date': tend, + '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 + + # Phase 2: cache miss — fall back to sequential TNR scan + saved = int(get_tnr_state('max_tnr_seen', '0')) + if max_tnr is None: + max_tnr = max(saved, discover_max_tnr()) + + windows = [(max_tnr, limit)] + + 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 or cache already populated + status = get_tnr_state('warmup_tnr', '') + if status == 'done': + return + conn = _get_conn() + cache_count = conn.execute('SELECT COUNT(*) FROM tnr_cache').fetchone()[0] + conn.close() + if cache_count > 1000: + set_tnr_state('warmup_tnr', '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 @@ -316,7 +726,8 @@ async def check_all_subscriptions(context): 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']) + fmt = format_pairings(result, tournament.get('name', ''), sub['lang'], + sub['player_name'], sub['player_sno']) chunks = _render_chunks(fmt) for chunk in chunks: try: @@ -329,3 +740,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))