Compare commits
No commits in common. "07b016bff9efd1e1ecb2036942c05be0ee187921" and "7ebe1aa66e814df06bd31f8d1db6acf211d2c32a" have entirely different histories.
07b016bff9
...
7ebe1aa66e
2 changed files with 31 additions and 586 deletions
|
|
@ -104,15 +104,12 @@ 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 = '',
|
def format_pairings(pairings_data: dict, tournament_name: str, lang: str = '') -> 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']
|
||||||
|
|
||||||
# If highlighting a player, reduce name width to compensate for ✅ emoji (visually 2 chars wide)
|
w_board, w_name, w_rating, w_pts, w_tb1 = 2, 17, 4, 4, 5
|
||||||
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)}|'
|
||||||
|
|
@ -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 \
|
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': title,
|
'title': locale['title'].format(rnd=rnd),
|
||||||
'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'], player_name, player_sno),
|
locale['bye']),
|
||||||
'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 —',
|
w_pts: int, w_tb1: int, border: str, bye_label: str = '— BYE —') -> list:
|
||||||
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):
|
||||||
|
|
@ -187,16 +145,10 @@ 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(_name_cell(p1.name), w_name)}|'
|
f'{_pad_col(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)}'
|
||||||
|
|
@ -209,7 +161,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) and board <= hi:
|
if pi + 1 < len(pairings):
|
||||||
rows.append(border)
|
rows.append(border)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -220,21 +172,22 @@ 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(_name_cell(white.name), w_name)}|'
|
f'{_pad_col(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(_name_cell(black.name), w_name)}|'
|
f'{_pad_col(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
|
||||||
|
|
||||||
if pi + 1 < len(pairings) and board <= hi:
|
# Separator between pairs
|
||||||
|
if pi + 1 < len(pairings):
|
||||||
rows.append(border)
|
rows.append(border)
|
||||||
|
|
||||||
return rows
|
return rows
|
||||||
|
|
@ -309,10 +262,9 @@ 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} — {last_pts_str} очк\\.')
|
f'— {safe_tour} — {s["last_points"]:.1f} очк\\.')
|
||||||
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)
|
||||||
|
|
@ -363,47 +315,19 @@ 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, '
|
||||||
parse_mode=ParseMode.MARKDOWN_V2)
|
'где играет этот игрок\.\n'
|
||||||
|
'/cancel — отмена'
|
||||||
# Run scan asynchronously (avoid blocking the event loop)
|
)
|
||||||
# Use canonical FIDE ID from profile page (may differ from URL-extracted ID)
|
context.user_data['pending_fide'] = {
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
'player': player,
|
||||||
loop = asyncio.get_event_loop()
|
'lang': lang_code,
|
||||||
tournaments = await loop.run_in_executor(
|
}
|
||||||
None, tracker.scan_for_player, player['fide_id'])
|
await update.message.reply_text(msg, parse_mode=ParseMode.MARKDOWN_V2)
|
||||||
|
|
||||||
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):
|
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(
|
await msg.edit_text(
|
||||||
f'✅ *{safe_name}* найден в турнире\\.\n'
|
f'✅ *{safe_name}* найден в турнире\\.\n'
|
||||||
f'📅 *{safe_tour}*\n'
|
f'📅 *{safe_tour}*\n'
|
||||||
f'Ст\\.№ {p_sno}, {_md_escape(f"{points:.1f}")} очк\\. — отслеживаю\!\n\n'
|
f'Ст\\.№ {p_sno}, {points:.1f} очк\\. — отслеживаю\!\n\n'
|
||||||
'Я буду присылать результаты игрока и жеребьёвку следующих туров\.\n'
|
'Я буду присылать результаты игрока и жеребьёвку следующих туров\.\n'
|
||||||
'/myplayers — список подписок',
|
'/myplayers — список подписок',
|
||||||
parse_mode=ParseMode.MARKDOWN_V2)
|
parse_mode=ParseMode.MARKDOWN_V2)
|
||||||
|
|
@ -654,20 +578,7 @@ 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:
|
||||||
|
|
@ -719,12 +630,6 @@ 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()
|
||||||
|
|
|
||||||
472
bots/tracker.py
472
bots/tracker.py
|
|
@ -2,7 +2,6 @@
|
||||||
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
|
||||||
|
|
@ -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():
|
def _get_conn():
|
||||||
p = Path(DB_PATH)
|
p = Path(DB_PATH)
|
||||||
try:
|
try:
|
||||||
|
|
@ -64,31 +57,10 @@ 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
|
||||||
|
|
||||||
|
|
@ -135,11 +107,6 @@ 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,
|
||||||
|
|
@ -151,17 +118,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, start_date: str = '', end_date: str = ''):
|
lang: 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, start_date, end_date, active)
|
last_round_done, 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, start_date, end_date))
|
player_sno, last_results_count, last_points))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
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_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'
|
||||||
|
|
@ -306,383 +272,7 @@ def format_result(player_name: str, result: dict, opponent_name: str,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ═══ TNR state cache ═══
|
# ═══ Background check ═══
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -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):
|
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:
|
||||||
|
|
@ -740,52 +329,3 @@ 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))
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue