ChessCalcNextTour/bots/tracker.py
vrubel 77c8a87e16
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 4s
fix: stop burning request limit on finished tournaments + reset stale result counts
check_all_subscriptions: always call update_round even for the final round.
Previously, last_round_done was never updated past the second-to-last round
(because pairings aren't calculated for the last round). This kept
quick_rd > last_round_done forever on finished tournaments, triggering a full
fetch (~22 HTTP requests) every 5 minutes — causing 26k+ daily requests to
chess-results.com against a 2000-request daily limit.

rescan_existing_tournaments: when correcting player_sno, also reset
last_results_count to 0. Stale counts from a wrong player (from the FIDE ID
parsing bug) could be higher than the real player's count, permanently
suppressing result notifications for the correct player.

DB patched directly: last_results_count reset to 0 for subs 40 (Vrubel)
and 47 (Aslanbekov) so they re-learn correct scores on next fetch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 15:19:39 +00:00

1008 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Player tracking: FIDE profile lookup, subscription management,
background polling for new results and pairings.
"""
import json
import re
import os
import sys
import sqlite3
import requests
from pathlib import Path
from datetime import date
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
DB_PATH = os.environ.get('STATS_DB', '/app/data/tournaments.db')
FIDE_URL = 'https://ratings.fide.com/profile/{fide_id}'
HEADERS = {
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36'
}
RESULT_LOCALE = {
'ru': {
'win': 'победа', 'draw': 'ничья', 'loss': 'поражение',
'white': 'белыми', 'black': 'чёрными',
'points': 'Очков', 'round': 'тур', 'vs': 'против',
},
'en': {
'win': 'win', 'draw': 'draw', 'loss': 'loss',
'white': 'White', 'black': 'Black',
'points': 'Points', 'round': 'round', 'vs': 'vs',
},
}
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:
p.parent.mkdir(parents=True, exist_ok=True)
except PermissionError:
fallback = Path(__file__).parent.parent / 'data' / 'tournaments.db'
fallback.parent.mkdir(parents=True, exist_ok=True)
p = fallback
conn = sqlite3.connect(str(p))
conn.execute('PRAGMA journal_mode=WAL')
conn.execute('''
CREATE TABLE IF NOT EXISTS subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
fide_id INTEGER NOT NULL,
player_name TEXT NOT NULL,
rating INTEGER DEFAULT 0,
fed TEXT DEFAULT '',
lang TEXT DEFAULT 'en',
tournament_url TEXT NOT NULL,
tournament_name TEXT DEFAULT '',
player_sno INTEGER DEFAULT 0,
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 ''")
_migrate_add_column(conn, 'subscriptions', 'rapid', "INTEGER DEFAULT 0")
_migrate_add_column(conn, 'subscriptions', 'blitz', "INTEGER DEFAULT 0")
conn.commit()
return conn
# ═══ FIDE profile scraper ═══
def fetch_fide_player(fide_id: int) -> dict:
url = FIDE_URL.format(fide_id=fide_id)
resp = requests.get(url, headers=HEADERS, timeout=20)
if resp.status_code != 200:
raise RuntimeError(f'FIDE profile not found (HTTP {resp.status_code})')
html = resp.text
# Check for "No record found"
if 'No record found' in html:
raise RuntimeError(f'Player with FIDE ID {fide_id} not found')
# Name: <h1 class="player-title">Carlsen, Magnus</h1>
name = ''
m = re.search(r'<h1\s+class="player-title"[^>]*>\s*(.*?)\s*</h1>', html)
if m:
name = m.group(1).strip()
name = re.sub(r'<[^>]+>', '', name).strip()
if not name:
raise RuntimeError('Could not extract player name from FIDE profile')
# Federation: "National Rank NOR"
fed = ''
m = re.search(r'National Rank\s+([A-Z]{3})', html)
if m:
fed = m.group(1)
# Ratings: <p>2841</p><p ...>STANDARD / RAPID / BLITZ
rating = 0
m = re.search(r'<p>(\d{3,4})</p>\s*<p[^>]*>\s*STANDARD', html)
if m:
rating = int(m.group(1))
rapid = 0
m = re.search(r'<p>(\d{3,4})</p>\s*<p[^>]*>\s*RAPID', html)
if m:
rapid = int(m.group(1))
blitz = 0
m = re.search(r'<p>(\d{3,4})</p>\s*<p[^>]*>\s*BLITZ', html)
if m:
blitz = int(m.group(1))
# Title: <div class="profile-info-title "><p>Grandmaster</p>
title = ''
m = re.search(r'<div\s+class="profile-info-title[^"]*"\s*>\s*<p>(.*?)</p>', html)
if m:
title = m.group(1).strip()
# Some players have multiple titles in adjacent <p> — take only the first one
title = re.sub(r'<[^>]+>', '', title).strip()
# Canonical FIDE ID from profile page: <p class="profile-info-id ">1503014</p>
m = re.search(r'<p\s+class="profile-info-id\s*">\s*(\d+)\s*</p>', html)
if m:
fide_id = int(m.group(1))
return {
'fide_id': fide_id, 'name': name, 'rating': rating,
'rapid': rapid, 'blitz': blitz,
'fed': fed, 'title': title,
}
# ═══ Subscription CRUD ═══
def add_player_watch(user_id: int, player: dict, lang: str):
"""Subscribe to a player with no active tournament yet.
rescan_new_tournaments will auto-upgrade this to a real subscription
when a tournament appears.
"""
conn = _get_conn()
conn.execute('''
INSERT INTO subscriptions
(user_id, fide_id, player_name, rating, rapid, blitz, fed, lang, tournament_url,
tournament_name, player_sno, last_results_count, last_points,
last_round_done, active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'fide_only', '', 0, 0, 0.0, 0, 1)
ON CONFLICT(user_id, fide_id, tournament_url) DO UPDATE SET
active = 1,
player_name = excluded.player_name,
rating = excluded.rating,
rapid = excluded.rapid,
blitz = excluded.blitz
''', (user_id, player['fide_id'], player['name'], player.get('rating', 0),
player.get('rapid', 0), player.get('blitz', 0), player.get('fed', ''), lang))
conn.commit()
conn.close()
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 = ''):
conn = _get_conn()
conn.execute('''
INSERT OR REPLACE INTO subscriptions
(user_id, fide_id, player_name, rating, rapid, blitz, fed, lang, tournament_url,
tournament_name, player_sno, last_results_count, last_points,
last_round_done, start_date, end_date, active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, 1)
''', (user_id, player['fide_id'], player['name'], player.get('rating', 0),
player.get('rapid', 0), player.get('blitz', 0), player.get('fed', ''),
lang, tournament_url, tournament_name,
player_sno, last_results_count, last_points, start_date, end_date))
conn.commit()
conn.close()
def get_user_subs(user_id: int) -> list:
"""Return subscriptions grouped by player (fide_id)."""
conn = _get_conn()
rows = conn.execute(
'SELECT fide_id, player_name, rating, rapid, blitz, fed, tournament_name, '
'last_points, last_round_done, tournament_url FROM subscriptions '
'WHERE user_id = ? AND active = 1 ORDER BY fide_id, id',
(user_id,)).fetchall()
conn.close()
players: dict = {}
for r in rows:
fide_id, pname, rating, rapid, blitz, fed, tname, pts, rd, url = r
if fide_id not in players:
players[fide_id] = {
'fide_id': fide_id, 'player_name': pname,
'rating': rating, 'rapid': rapid or 0, 'blitz': blitz or 0,
'fed': fed or '',
'tournaments': [],
}
if url == 'fide_only':
players[fide_id]['tournaments'].append({
'name': None, 'url': None, 'points': pts, 'round_done': rd,
})
else:
players[fide_id]['tournaments'].append({
'name': tname or url, 'url': url, 'points': pts, 'round_done': rd,
})
return list(players.values())
def get_active_subs() -> list:
conn = _get_conn()
rows = conn.execute(
'SELECT id, user_id, fide_id, player_name, rating, fed, lang, '
'tournament_url, tournament_name, player_sno, last_results_count, '
'last_points, last_round_done FROM subscriptions '
'WHERE active = 1').fetchall()
conn.close()
return [dict(zip(
['id', 'user_id', 'fide_id', 'player_name', 'rating', 'fed', 'lang',
'tournament_url', 'tournament_name', 'player_sno',
'last_results_count', 'last_points', 'last_round_done'], r))
for r in rows]
def remove_subscription(sub_id: int, user_id: int) -> bool:
conn = _get_conn()
cur = conn.execute(
'UPDATE subscriptions SET active = 0 WHERE id = ? AND user_id = ?',
(sub_id, user_id))
conn.commit()
deleted = cur.rowcount > 0
conn.close()
return deleted
def remove_player_subscriptions(fide_id: int, user_id: int) -> bool:
"""Deactivate all subscriptions for a player (by FIDE ID)."""
conn = _get_conn()
cur = conn.execute(
'UPDATE subscriptions SET active = 0 WHERE fide_id = ? AND user_id = ?',
(fide_id, user_id))
conn.commit()
deleted = cur.rowcount > 0
conn.close()
return deleted
def update_result(sub_id: int, results_count: int, points: float):
conn = _get_conn()
conn.execute(
'UPDATE subscriptions SET last_results_count = ?, last_points = ? '
'WHERE id = ?',
(results_count, points, sub_id))
conn.commit()
conn.close()
def update_round(sub_id: int, round_num: int):
conn = _get_conn()
conn.execute(
'UPDATE subscriptions SET last_round_done = ? WHERE id = ?',
(round_num, sub_id))
conn.commit()
conn.close()
# ═══ Tournament player lookup ═══
def _normalize(s: str) -> str:
return re.sub(r'\s+', ' ', s.strip()).lower()
def find_player(tournament: dict, name: str, sno: int = 0) -> dict | None:
standings = tournament.get('standings', [])
# Try exact SNo match first
if sno:
for s in standings:
if s.get('starting_sno') == sno:
return s
# Try exact name match (normalized)
norm_target = _normalize(name)
for s in standings:
if _normalize(s['name']) == norm_target:
return s
# Fuzzy: split into parts, find best overlap
target_parts = set(norm_target.split())
best, best_overlap = None, 0
for s in standings:
s_parts = set(_normalize(s['name']).split())
overlap = len(target_parts & s_parts)
if overlap > best_overlap:
best_overlap = overlap
best = s
if best and best_overlap >= 1:
return best
return None
def find_opponent_name(tournament: dict, opp_sno: int) -> str:
if not opp_sno:
return 'BYE'
standings = tournament.get('standings', [])
for s in standings:
if s.get('starting_sno') == opp_sno:
return s['name']
return f'#{opp_sno}'
# ═══ Result formatting ═══
def _md_escape_tracker(text: str) -> str:
escape_chars = r'_*[]()~`>#+-=|{}.!'
result = []
for ch in text:
if ch in escape_chars:
result.append('\\' + ch)
else:
result.append(ch)
return ''.join(result)
def format_result(player_name: str, result: dict, opponent_name: str,
points: float, lang: str) -> str:
loc = RESULT_LOCALE.get(lang, RESULT_LOCALE['en'])
score = result.get('score', 0.0)
color = result.get('color', '')
rd = result.get('round', '?')
score_label = {1.0: loc['win'], 0.5: loc['draw'], 0.0: loc['loss']}
score_desc = score_label.get(score, f'{score}')
color_desc = loc['white'] if color == 'w' else loc['black']
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'
f'{score_desc} \({color_desc}\) {loc["vs"]} {safe_opp}\n'
f'{loc["points"]}: {points:.1f}'
)
# ═══ 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
def _refetch_art0(tnr: int) -> dict | None:
"""Always fetch art=0 via HTTP, update cache. Returns parsed dict or None."""
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>
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):
# Only capture the FIRST 6-10 digit number as FIDE ID.
# Subsequent matches are national IDs — must not overwrite.
if re.match(r'^\d{6,10}$', t) and not fide_id:
fide_id = int(t)
# Scan forward for 3-letter FED code (skipping national ID)
for look in range(1, min(5, len(texts) - ci)):
nt = texts[ci + look]
if re.match(r'^[A-Z]{3}$', nt):
fed = nt
# Rating is right after FED
if ci + look + 1 < len(texts):
rt = texts[ci + look + 1]
if rt.isdigit() and int(rt) <= 4000:
rating = int(rt)
break
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 <h2> and are >30KB
if '<h2' not in html or len(html) < 30000:
return None
return html
except requests.RequestException:
time.sleep(1)
return None
def discover_max_tnr() -> 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)
# Use max cached TNR as a better starting point (avoids stuck-at-500K problem)
conn = _get_conn()
row = conn.execute('SELECT MAX(tnr) FROM tnr_cache').fetchone()
conn.close()
if row and row[0]:
tnr = max(tnr, row[0])
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 find_player_in_cache(fide_id: int) -> list:
"""Instant SQL-only search. Returns same format as scan_for_player."""
conn = _get_conn()
pattern = f'%"{fide_id}"%'
rows = conn.execute(
'SELECT tnr, name, start_date, end_date, players_json FROM tnr_cache '
'WHERE players_json LIKE ? ORDER BY tnr DESC',
(pattern,)).fetchall()
conn.close()
results = []
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
def scan_for_player(fide_id: int, max_tnr: int = None,
limit: int = 200) -> list:
"""Scan tournaments for a player by FIDE ID (SQL cache + HTTP fallback).
Used by background jobs. For user-facing flows use find_player_in_cache().
"""
import time
results = []
seen_tnrs = set()
# Phase 1: SQL cache (instant)
results = find_player_in_cache(fide_id)
if 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:
print(f'warmup: progress TNR {tnr} (started at {start_tnr})', file=sys.stderr)
# Rate-limit: ~1 req/sec to stay within chess-results.com daily limit.
# Cached TNRs return instantly (no HTTP), so this only applies to misses.
time.sleep(1.0)
# Mark warmup as complete — next run will see this and skip
set_tnr_state('warmup_tnr', 'done')
async def rescan_existing_tournaments(context):
"""Every 2h: refetch art=0 for recent/active cached TNRs, detect new players.
Only rescans tournaments that ended within the last 30 days (or have no
end_date). Old completed tournaments never get new players, so skipping
them keeps daily request volume within chess-results.com rate limits.
"""
import time
from datetime import timedelta
from bots.client_bot import _md_escape as _md_esc
cutoff = (date.today() - timedelta(days=30)).isoformat()
conn = _get_conn()
cached_tnrs = [r[0] for r in conn.execute(
'SELECT tnr FROM tnr_cache WHERE end_date = "" OR end_date >= ? ORDER BY tnr DESC',
(cutoff,)).fetchall()]
conn.close()
if not cached_tnrs:
return
# Collect subscribed FIDE IDs and their users
conn = _get_conn()
sub_rows = conn.execute(
'SELECT DISTINCT user_id, fide_id, lang FROM subscriptions WHERE active = 1'
).fetchall()
conn.close()
if not sub_rows:
return
fide_to_users = {}
for user_id, fide_id, lang in sub_rows:
fide_to_users.setdefault(fide_id, []).append((user_id, lang))
for tnr in cached_tnrs:
old_data = _get_cached_tnr(tnr)
old_fids = set(old_data['players'].keys()) if old_data else set()
new_data = _refetch_art0(tnr)
if new_data is None:
continue # tournament deleted or unreachable
new_fids = set(new_data['players'].keys())
# Correct player_sno for subscriptions whose SNo drifted from the cache.
# Also reset last_results_count so the bot re-learns points for the
# correct player (stale counts from the old wrong player could be higher
# than the real player's count, suppressing result notifications forever).
tnr_url_frag = f'tnr{tnr}.aspx'
for fid in new_fids:
if fid not in fide_to_users:
continue
correct_sno = new_data['players'][fid]['sno']
conn = _get_conn()
conn.execute(
'UPDATE subscriptions SET player_sno = ?, last_results_count = 0 '
'WHERE fide_id = ? AND tournament_url LIKE ? '
'AND player_sno != ? AND active = 1',
(correct_sno, fid, f'%{tnr_url_frag}%', correct_sno))
conn.commit()
conn.close()
# New players that appeared since last cached snapshot
appeared = new_fids - old_fids
for fid in appeared:
if fid not in fide_to_users:
continue
player_info = new_data['players'][fid]
url = f'https://chess-results.com/tnr{tnr}.aspx?lan=11'
for user_id, lang in fide_to_users[fid]:
player = {'fide_id': fid, 'name': player_info['name'],
'rating': player_info.get('rating', 0), 'fed': ''}
add_subscription(
user_id, player, url, new_data['name'] or f'Tournament {tnr}',
player_info['sno'], 0, 0.0, lang,
new_data.get('start_date', ''), new_data.get('end_date', ''))
try:
safe_name = _md_esc(player_info['name'])
safe_tour = _md_esc(new_data['name'] or f'Tournament {tnr}')
dates = ''
if new_data.get('start_date') and new_data.get('end_date'):
dates = f'\\({new_data["start_date"]}{new_data["end_date"]}\\)'
await context.bot.send_message(
user_id,
f'🆕 *{safe_name}* появился в турнире\!\n'
f'📅 *{safe_tour}*{dates}\n'
'Автоматически подписал — буду отслеживать\.',
parse_mode='MarkdownV2')
except Exception:
pass
time.sleep(0.1)
async def check_all_subscriptions(context):
from swiss_calc.parser import fetch_tournament, fetch_tournament_quick
from swiss_calc.swiss import calculate_next_round
from bots.client_bot import format_pairings, _render_chunks
from telegram.constants import ParseMode
subs = get_active_subs()
if not subs:
return
for sub in subs:
if sub['tournament_url'] == 'fide_only':
continue
# Quick check: 1 request — skip full fetch if nothing changed
try:
quick = fetch_tournament_quick(sub['tournament_url'])
except Exception:
continue
quick_player = find_player(quick, sub['player_name'], sub['player_sno'])
quick_results = len(quick_player.get('results', [])) if quick_player else 0
quick_rd = quick.get('current_round', 0)
if quick_results <= sub['last_results_count'] and quick_rd <= sub['last_round_done']:
continue
# Something changed — do full fetch for accurate data and pairing calc
try:
tournament = fetch_tournament(sub['tournament_url'])
except Exception:
continue
player = find_player(tournament, sub['player_name'], sub['player_sno'])
if player is None:
continue
results_count = len(player.get('results', []))
points = player.get('points', 0.0)
# New individual result detected
if results_count > sub['last_results_count']:
new_r = player['results'][-1]
opp_name = find_opponent_name(tournament, new_r.get('opponent', 0))
msg = format_result(
player['name'], new_r, opp_name, points, sub['lang'])
try:
await context.bot.send_message(
sub['user_id'], msg, parse_mode=ParseMode.MARKDOWN_V2)
except Exception:
pass
update_result(sub['id'], results_count, points)
# Round fully completed → calculate next round pairings (if not the last round)
current_rd = tournament.get('current_round', 0)
if current_rd > sub['last_round_done']:
if 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'])
chunks = _render_chunks(fmt)
for chunk in chunks:
try:
await context.bot.send_message(
sub['user_id'], chunk,
parse_mode=ParseMode.MARKDOWN_V2,
disable_web_page_preview=True)
except Exception:
break
except Exception:
pass
# Always advance last_round_done — even for the final round.
# Without this, the quick-check condition (quick_rd > last_round_done)
# stays True forever on finished tournaments, causing a full fetch
# (~22 HTTP requests) every 5 minutes.
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'])
# Deactivate the fide_only watch entry now that we have a real tournament
conn = _get_conn()
conn.execute(
"UPDATE subscriptions SET active = 0 "
"WHERE user_id = ? AND fide_id = ? AND tournament_url = 'fide_only'",
(user_id, fide_id))
conn.commit()
conn.close()
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))