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

- 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') - +# ═══ Background check ═══ async def check_all_subscriptions(context): from swiss_calc.parser import fetch_tournament @@ -726,8 +316,7 @@ 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'], - sub['player_name'], sub['player_sno']) + fmt = format_pairings(result, tournament.get('name', ''), sub['lang']) chunks = _render_chunks(fmt) for chunk in chunks: try: @@ -740,52 +329,3 @@ 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))