feat: rapid/blitz ratings, instant addplayer, hyperlinks, bug fixes
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 7s

- fetch_fide_player: parse rapid and blitz ratings from FIDE profile
- subscriptions: add rapid/blitz columns (auto-migration)
- /myplayers card: show rapid (📍) and blitz () ratings
- /addplayer confirmation: show rapid/blitz alongside standard rating
- find_player_in_cache: extract instant SQL-only search from scan_for_player
- /addplayer: use find_player_in_cache instead of full HTTP scan — instant response
- tournament names in /myplayers and /addplayer are now hyperlinks
- add_player_watch: ON CONFLICT DO UPDATE to reactivate deleted players
- my_players: try/except per card with plain-text fallback, error logged to stderr

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
vrubel 2026-06-20 23:35:49 +00:00
parent 39f1f7e857
commit 66b10d9425
2 changed files with 92 additions and 43 deletions

View file

@ -332,8 +332,16 @@ def _player_card(p: dict, loc: dict) -> tuple[str, InlineKeyboardMarkup]:
rating = p['rating'] or '' rating = p['rating'] or ''
fide_id = p['fide_id'] fide_id = p['fide_id']
rapid = p.get('rapid') or 0
blitz = p.get('blitz') or 0
ratings = str(rating)
if rapid:
ratings += f' 📍{rapid}'
if blitz:
ratings += f'{blitz}'
lines = [f'👤 *{safe_name}*'] lines = [f'👤 *{safe_name}*']
lines.append(f'🏳 {_md_escape(fed)} · {rating}') lines.append(f'🏳 {_md_escape(fed)} · {_md_escape(ratings)}')
tours = p['tournaments'] tours = p['tournaments']
waiting = all(t['name'] is None for t in tours) waiting = all(t['name'] is None for t in tours)
@ -343,10 +351,13 @@ def _player_card(p: dict, loc: dict) -> tuple[str, InlineKeyboardMarkup]:
for t in tours: for t in tours:
if t['name'] is None: if t['name'] is None:
continue continue
safe_tour = _md_escape(t['name'])
pts = _md_escape(f'{t["points"]:.1f}') pts = _md_escape(f'{t["points"]:.1f}')
rd = t['round_done'] rd = t['round_done']
lines.append(f'📌 {safe_tour}') if t.get('url'):
tour_link = f'[{_md_escape(t["name"])}]({t["url"]})'
else:
tour_link = _md_escape(t['name'])
lines.append(f'📌 {tour_link}')
lines.append(f' {pts} {loc["points_label"]}, {loc["round_label"]} {rd}') lines.append(f' {pts} {loc["points_label"]}, {loc["round_label"]} {rd}')
keyboard = InlineKeyboardMarkup([[ keyboard = InlineKeyboardMarkup([[
@ -368,8 +379,17 @@ async def my_players(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text(header, parse_mode=ParseMode.MARKDOWN_V2) await update.message.reply_text(header, parse_mode=ParseMode.MARKDOWN_V2)
for p in players: for p in players:
text, keyboard = _player_card(p, loc) text, keyboard = _player_card(p, loc)
await update.message.reply_text(text, parse_mode=ParseMode.MARKDOWN_V2, try:
reply_markup=keyboard) await update.message.reply_text(text, parse_mode=ParseMode.MARKDOWN_V2,
reply_markup=keyboard,
disable_web_page_preview=True)
except Exception as e:
import sys
print(f'my_players card error: {e}\ntext={text!r}', file=sys.stderr)
# Fallback: send as plain text without formatting
plain = text.replace('\\', '').replace('*', '').replace('_', '')
await update.message.reply_text(plain, reply_markup=keyboard,
disable_web_page_preview=True)
async def remove_player_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): async def remove_player_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
@ -422,18 +442,20 @@ async def _lookup_and_confirm(update: Update, context, fide_id: int):
title = player.get('title') or '' title = player.get('title') or ''
title_str = f', {_md_escape(title)}' if title and title != 'None' else '' title_str = f', {_md_escape(title)}' if title and title != 'None' else ''
fed = player.get('fed') or '' fed = player.get('fed') or ''
ratings_parts = [f'Рейтинг: {player["rating"]}']
if player.get('rapid'):
ratings_parts.append(f'📍{player["rapid"]}')
if player.get('blitz'):
ratings_parts.append(f'{player["blitz"]}')
ratings_str = _md_escape(' '.join(ratings_parts))
await update.message.reply_text( await update.message.reply_text(
f'✅ *{safe_name}*{title_str}\n' f'✅ *{safe_name}*{title_str}\n'
f'{"🇫 " if fed else ""}{_md_escape(fed)} \\| Рейтинг: {player["rating"]}\n\n' f'{"🇫 " if fed else ""}{_md_escape(fed)} \\| {ratings_str}\n\n'
'🔍 Ищу турниры на chess\\-results\\.com\\.\\.', '🔍 Ищу турниры на chess\\-results\\.com\\.\\.',
parse_mode=ParseMode.MARKDOWN_V2) parse_mode=ParseMode.MARKDOWN_V2)
# Run scan asynchronously (avoid blocking the event loop) # SQL-only cache search (instant, no HTTP). Background jobs handle HTTP discovery.
# Use canonical FIDE ID from profile page (may differ from URL-extracted ID) tournaments = tracker.find_player_in_cache(player['fide_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: if tournaments:
# Auto-subscribe to all found tournaments # Auto-subscribe to all found tournaments
@ -445,8 +467,8 @@ async def _lookup_and_confirm(update: Update, context, fide_id: int):
dates = '' dates = ''
if t['start_date'] and t['end_date']: if t['start_date'] and t['end_date']:
dates = f' \\({_md_escape(t["start_date"])}{_md_escape(t["end_date"])}\\)' dates = f' \\({_md_escape(t["start_date"])}{_md_escape(t["end_date"])}\\)'
safe_tour = _md_escape(t['name']) tour_link = f'[{_md_escape(t["name"])}]({t["url"]})'
lines.append(f'{safe_tour}{dates}') lines.append(f'{tour_link}{dates}')
lines.append('\nЯ буду присылать результаты и жеребьёвку\.\n' lines.append('\nЯ буду присылать результаты и жеребьёвку\.\n'
'/myplayers \\- список подписок') '/myplayers \\- список подписок')
await update.message.reply_text('\n'.join(lines), await update.message.reply_text('\n'.join(lines),

View file

@ -89,6 +89,8 @@ def _get_conn():
# Migrate existing tables that may be missing columns added in later versions # 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', 'start_date', "TEXT DEFAULT ''")
_migrate_add_column(conn, 'subscriptions', 'end_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() conn.commit()
return conn return conn
@ -121,12 +123,22 @@ def fetch_fide_player(fide_id: int) -> dict:
if m: if m:
fed = m.group(1) fed = m.group(1)
# Rating (standard): <p>2841</p><p ...>STANDARD # Ratings: <p>2841</p><p ...>STANDARD / RAPID / BLITZ
rating = 0 rating = 0
m = re.search(r'<p>(\d{3,4})</p>\s*<p[^>]*>\s*STANDARD', html) m = re.search(r'<p>(\d{3,4})</p>\s*<p[^>]*>\s*STANDARD', html)
if m: if m:
rating = int(m.group(1)) 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: <div class="profile-info-title "><p>Grandmaster</p>
title = '' title = ''
m = re.search(r'<div\s+class="profile-info-title[^"]*"\s*>\s*<p>(.*?)</p>', html) m = re.search(r'<div\s+class="profile-info-title[^"]*"\s*>\s*<p>(.*?)</p>', html)
@ -142,6 +154,7 @@ def fetch_fide_player(fide_id: int) -> dict:
return { return {
'fide_id': fide_id, 'name': name, 'rating': rating, 'fide_id': fide_id, 'name': name, 'rating': rating,
'rapid': rapid, 'blitz': blitz,
'fed': fed, 'title': title, 'fed': fed, 'title': title,
} }
@ -156,13 +169,19 @@ def add_player_watch(user_id: int, player: dict, lang: str):
""" """
conn = _get_conn() conn = _get_conn()
conn.execute(''' conn.execute('''
INSERT OR IGNORE INTO subscriptions INSERT INTO subscriptions
(user_id, fide_id, player_name, rating, fed, lang, tournament_url, (user_id, fide_id, player_name, rating, rapid, blitz, 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, active)
VALUES (?, ?, ?, ?, ?, ?, 'fide_only', '', 0, 0, 0.0, 0, 1) 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), ''', (user_id, player['fide_id'], player['name'], player.get('rating', 0),
player.get('fed', ''), lang)) player.get('rapid', 0), player.get('blitz', 0), player.get('fed', ''), lang))
conn.commit() conn.commit()
conn.close() conn.close()
@ -174,12 +193,13 @@ def add_subscription(user_id: int, player: dict, tournament_url: 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, rapid, blitz, 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, 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.get('rating', 0),
player.get('fed', ''), lang, tournament_url, tournament_name, 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)) player_sno, last_results_count, last_points, start_date, end_date))
conn.commit() conn.commit()
conn.close() conn.close()
@ -189,7 +209,7 @@ def get_user_subs(user_id: int) -> list:
"""Return subscriptions grouped by player (fide_id).""" """Return subscriptions grouped by player (fide_id)."""
conn = _get_conn() conn = _get_conn()
rows = conn.execute( rows = conn.execute(
'SELECT fide_id, player_name, rating, fed, tournament_name, ' 'SELECT fide_id, player_name, rating, rapid, blitz, fed, tournament_name, '
'last_points, last_round_done, tournament_url FROM subscriptions ' 'last_points, last_round_done, tournament_url FROM subscriptions '
'WHERE user_id = ? AND active = 1 ORDER BY fide_id, id', 'WHERE user_id = ? AND active = 1 ORDER BY fide_id, id',
(user_id,)).fetchall() (user_id,)).fetchall()
@ -197,20 +217,21 @@ def get_user_subs(user_id: int) -> list:
players: dict = {} players: dict = {}
for r in rows: for r in rows:
fide_id, pname, rating, fed, tname, pts, rd, url = r fide_id, pname, rating, rapid, blitz, fed, tname, pts, rd, url = r
if fide_id not in players: if fide_id not in players:
players[fide_id] = { players[fide_id] = {
'fide_id': fide_id, 'player_name': pname, 'fide_id': fide_id, 'player_name': pname,
'rating': rating, 'fed': fed or '', 'rating': rating, 'rapid': rapid or 0, 'blitz': blitz or 0,
'fed': fed or '',
'tournaments': [], 'tournaments': [],
} }
if url == 'fide_only': if url == 'fide_only':
players[fide_id]['tournaments'].append({ players[fide_id]['tournaments'].append({
'name': None, 'points': pts, 'round_done': rd, 'name': None, 'url': None, 'points': pts, 'round_done': rd,
}) })
else: else:
players[fide_id]['tournaments'].append({ players[fide_id]['tournaments'].append({
'name': tname or url, 'points': pts, 'round_done': rd, 'name': tname or url, 'url': url, 'points': pts, 'round_done': rd,
}) })
return list(players.values()) return list(players.values())
@ -578,27 +599,16 @@ def discover_max_tnr() -> int:
return tnr return tnr
def scan_for_player(fide_id: int, max_tnr: int = None, def find_player_in_cache(fide_id: int) -> list:
limit: int = 200) -> list: """Instant SQL-only search. Returns same format as scan_for_player."""
"""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 full tnr_cache via SQL (single query, instant). Return if found.
conn = _get_conn() conn = _get_conn()
pattern = f'%"{fide_id}"%' pattern = f'%"{fide_id}"%'
rows = conn.execute( rows = conn.execute(
'SELECT tnr, name, start_date, end_date, players_json FROM tnr_cache ' 'SELECT tnr, name, start_date, end_date, players_json FROM tnr_cache '
'WHERE players_json LIKE ? ' 'WHERE players_json LIKE ? ORDER BY tnr DESC',
'ORDER BY tnr DESC',
(pattern,)).fetchall() (pattern,)).fetchall()
conn.close() conn.close()
results = []
for row in rows: for row in rows:
tnr, tname, tstart, tend, players_json = row tnr, tname, tstart, tend, players_json = row
players = {int(k): v for k, v in json.loads(players_json).items()} players = {int(k): v for k, v in json.loads(players_json).items()}
@ -617,6 +627,23 @@ def scan_for_player(fide_id: int, max_tnr: int = None,
}) })
if results: if results:
set_tnr_state('max_tnr_seen', str(max(r['tnr'] for r in 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 return results
# Phase 2: cache miss — fall back to sequential TNR scan # Phase 2: cache miss — fall back to sequential TNR scan