Compare commits

...

5 commits

Author SHA1 Message Date
vrubel
07b016bff9 perf: SQL LIKE query instead of Python loop; skip warmup if cache >1000
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 7s
2026-06-20 09:06:11 +00:00
vrubel
34dc8b7ac6 perf: check tnr_cache before discover_max_tnr (instant search) 2026-06-20 08:56:18 +00:00
vrubel
9a583c8fa5 fix: match player by SNo, not name (Latin/Cyrillic mismatch) 2026-06-20 08:55:09 +00:00
vrubel
ba293dcb70 feat: pairings output - show ±5 around subscribed player
- _build_table_rows: filter to ±5 boards around player
- Normalize names (strip commas) for matching FIDE vs pairings format
- Highlight subscribed player with  emoji
- Reduce name column width to compensate for emoji
- Add tournament name to pairing header
- Manual URL flow: look up subscription to highlight player
- Fix MarkdownV2 escaping for em-dash
2026-06-20 07:53:43 +00:00
vrubel
89f5861ee3 fix: auto-tournament search via local tnr_cache index
- 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)
2026-06-20 07:15:16 +00:00
2 changed files with 586 additions and 31 deletions

View file

@ -104,12 +104,15 @@ def _pad_col(s: str, width: int) -> str:
return s + ' ' * (width - cur) 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) locale = _get_locale(lang)
pairings = pairings_data['pairings'] pairings = pairings_data['pairings']
rnd = pairings_data['round'] 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]) 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)}|' 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 \ footer = locale['footer_bbp'] if source == 'bbp_pairings_fide_2025' else \
locale['footer_fallback'] if source == 'swiss_algorithm_simplified' 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 { 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, '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, 'table_header': table_header,
'footer': footer, 'footer': footer,
} }
def _build_table_rows(pairings: list, w_board: int, w_name: int, w_rating: int, 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): def _rating_str(p):
return str(p.rating) if p.rating else '' return str(p.rating) if p.rating else ''
def _tb1_str(p): def _tb1_str(p):
return f"{p.tb[0]:.1f}" if p.tb else '' 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 = [] rows = []
board = 1 board = 1
for pi, pairing in enumerate(pairings): 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 continue
p1, p2, color = pairing p1, p2, color = pairing
if board < lo:
board += 1
continue
if board > hi:
break
if color == 'bye': if color == 'bye':
rows.append( rows.append(
f'{_pad_col(str(board), w_board)}|' 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(_rating_str(p1), w_rating)}|'
f'{_pad_col(f"{p1.points:.1f}", w_pts)}|' f'{_pad_col(f"{p1.points:.1f}", w_pts)}|'
f'{_pad_col(_tb1_str(p1), w_tb1)}' 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)}' f'{_pad_col("", w_tb1)}'
) )
board += 1 board += 1
if pi + 1 < len(pairings): if pi + 1 < len(pairings) and board <= hi:
rows.append(border) rows.append(border)
continue continue
@ -172,22 +220,21 @@ def _build_table_rows(pairings: list, w_board: int, w_name: int, w_rating: int,
rows.append( rows.append(
f'{_pad_col(str(board), w_board)}|' 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(_rating_str(white), w_rating)}|'
f'{_pad_col(f"{white.points:.1f}", w_pts)}|' f'{_pad_col(f"{white.points:.1f}", w_pts)}|'
f'{_pad_col(_tb1_str(white), w_tb1)}' f'{_pad_col(_tb1_str(white), w_tb1)}'
) )
rows.append( rows.append(
f'{_pad_col("", w_board)}|' 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(_rating_str(black), w_rating)}|'
f'{_pad_col(f"{black.points:.1f}", w_pts)}|' f'{_pad_col(f"{black.points:.1f}", w_pts)}|'
f'{_pad_col(_tb1_str(black), w_tb1)}' f'{_pad_col(_tb1_str(black), w_tb1)}'
) )
board += 1 board += 1
# Separator between pairs if pi + 1 < len(pairings) and board <= hi:
if pi + 1 < len(pairings):
rows.append(border) rows.append(border)
return rows return rows
@ -262,9 +309,10 @@ async def my_players(update: Update, context: ContextTypes.DEFAULT_TYPE):
for s in subs: for s in subs:
safe_name = _md_escape(s['player_name']) safe_name = _md_escape(s['player_name'])
safe_tour = _md_escape(s['tournament_name'] or '') safe_tour = _md_escape(s['tournament_name'] or '')
last_pts_str = _md_escape(f'{s["last_points"]:.1f}')
lines.append( lines.append(
f' {s["id"]}\\. {safe_name} \({s["rating"]}, {s["fed"]}\) ' 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 <номер> — удалить подписку') lines.append('\n/removeplayer <номер> — удалить подписку')
await update.message.reply_text('\n'.join(lines), await update.message.reply_text('\n'.join(lines),
parse_mode=ParseMode.MARKDOWN_V2) 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']) safe_name = _md_escape(player['name'])
title_str = f', {player["title"]}' if player.get('title') else '' title_str = f', {player["title"]}' if player.get('title') else ''
await update.message.reply_text(
msg = (
f'✅ *{safe_name}*{title_str}\n' f'✅ *{safe_name}*{title_str}\n'
f'{"🇫 " if player["fed"] else ""}{player["fed"]} \\| Рейтинг: {player["rating"]}\n\n' f'{"🇫 " if player["fed"] else ""}{player["fed"]} \\| Рейтинг: {player["rating"]}\n\n'
'Теперь отправь ссылку на турнир chess\\-results\\.com, ' '🔍 Ищу турниры на chess\\-results\\.com\\.\\.',
'где играет этот игрок\.\n' parse_mode=ParseMode.MARKDOWN_V2)
'/cancel — отмена'
) # 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'] = { context.user_data['pending_fide'] = {
'player': player, 'player': player,
'lang': lang_code, 'lang': lang_code,
} }
await update.message.reply_text(msg, parse_mode=ParseMode.MARKDOWN_V2) await update.message.reply_text(
'❌ Турниров не найдено автоматически\\.\n\n'
'Отправь ссылку на турнир chess\\-results\\.com, '
'где играет этот игрок\\.\n'
'/cancel — отмена',
parse_mode=ParseMode.MARKDOWN_V2)
def _apply_round_results(tournament, games, rd): 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( await msg.edit_text(
f'✅ *{safe_name}* найден в турнире\\.\n' f'✅ *{safe_name}* найден в турнире\\.\n'
f'📅 *{safe_tour}*\n' f'📅 *{safe_tour}*\n'
f'Ст\\.№ {p_sno}, {points:.1f} очк\\. — отслеживаю\!\n\n' f'Ст\\.№ {p_sno}, {_md_escape(f"{points:.1f}")} очк\\. — отслеживаю\!\n\n'
'Я буду присылать результаты игрока и жеребьёвку следующих туров\.\n' 'Я буду присылать результаты игрока и жеребьёвку следующих туров\.\n'
'/myplayers — список подписок', '/myplayers — список подписок',
parse_mode=ParseMode.MARKDOWN_V2) parse_mode=ParseMode.MARKDOWN_V2)
@ -578,7 +654,20 @@ async def handle_url(update: Update, context: ContextTypes.DEFAULT_TYPE):
return return
lang_code = user.language_code or '' 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) chunks = _render_chunks(fmt)
try: try:
@ -630,6 +719,12 @@ def main():
app.job_queue.run_repeating( app.job_queue.run_repeating(
tracker.check_all_subscriptions, interval=300, first=10) 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) print('Client bot started', file=sys.stderr)
app.run_polling() app.run_polling()

View file

@ -2,6 +2,7 @@
Player tracking: FIDE profile lookup, subscription management, Player tracking: FIDE profile lookup, subscription management,
background polling for new results and pairings. background polling for new results and pairings.
""" """
import json
import re import re
import os import os
import sys 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(): def _get_conn():
p = Path(DB_PATH) p = Path(DB_PATH)
try: try:
@ -57,10 +64,31 @@ def _get_conn():
last_results_count INTEGER DEFAULT 0, last_results_count INTEGER DEFAULT 0,
last_points REAL DEFAULT 0.0, last_points REAL DEFAULT 0.0,
last_round_done INTEGER DEFAULT 0, last_round_done INTEGER DEFAULT 0,
start_date TEXT DEFAULT '',
end_date TEXT DEFAULT '',
active INTEGER DEFAULT 1, active INTEGER DEFAULT 1,
UNIQUE(user_id, fide_id, tournament_url) 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() conn.commit()
return conn return conn
@ -107,6 +135,11 @@ def fetch_fide_player(fide_id: int) -> dict:
# Some players have multiple titles in adjacent <p> — take only the first one # Some players have multiple titles in adjacent <p> — take only the first one
title = re.sub(r'<[^>]+>', '', title).strip() 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 { return {
'fide_id': fide_id, 'name': name, 'rating': rating, 'fide_id': fide_id, 'name': name, 'rating': rating,
'fed': fed, 'title': title, '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, def add_subscription(user_id: int, player: dict, tournament_url: str,
tournament_name: str, player_sno: int, tournament_name: str, player_sno: int,
last_results_count: int, last_points: float, last_results_count: int, last_points: float,
lang: str): lang: str, start_date: str = '', end_date: str = ''):
conn = _get_conn() conn = _get_conn()
conn.execute(''' conn.execute('''
INSERT OR REPLACE INTO subscriptions INSERT OR REPLACE INTO subscriptions
(user_id, fide_id, player_name, rating, fed, lang, tournament_url, (user_id, fide_id, player_name, rating, fed, lang, tournament_url,
tournament_name, player_sno, last_results_count, last_points, tournament_name, player_sno, last_results_count, last_points,
last_round_done, active) last_round_done, start_date, end_date, active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 1) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, 1)
''', (user_id, player['fide_id'], player['name'], player['rating'], ''', (user_id, player['fide_id'], player['name'], player['rating'],
player.get('fed', ''), lang, tournament_url, tournament_name, 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.commit()
conn.close() 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_name = _md_escape_tracker(player_name)
safe_opp = _md_escape_tracker(opponent_name) safe_opp = _md_escape_tracker(opponent_name)
safe_points = _md_escape_tracker(f'{points:.1f}')
return ( return (
f'♟ *{safe_name}* — {loc["round"]} {rd}\n' 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>
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 <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)
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): async def check_all_subscriptions(context):
from swiss_calc.parser import fetch_tournament 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): if current_rd > sub['last_round_done'] and current_rd < tournament.get('num_rounds', 0):
try: try:
result = calculate_next_round(tournament) 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) chunks = _render_chunks(fmt)
for chunk in chunks: for chunk in chunks:
try: try:
@ -329,3 +740,52 @@ async def check_all_subscriptions(context):
except Exception: except Exception:
pass pass
update_round(sub['id'], current_rd) 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))