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 ''
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.append(f'🏳 {_md_escape(fed)} · {rating}')
lines.append(f'🏳 {_md_escape(fed)} · {_md_escape(ratings)}')
tours = p['tournaments']
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:
if t['name'] is None:
continue
safe_tour = _md_escape(t['name'])
pts = _md_escape(f'{t["points"]:.1f}')
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}')
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)
for p in players:
text, keyboard = _player_card(p, loc)
try:
await update.message.reply_text(text, parse_mode=ParseMode.MARKDOWN_V2,
reply_markup=keyboard)
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):
@ -422,18 +442,20 @@ async def _lookup_and_confirm(update: Update, context, fide_id: int):
title = player.get('title') or ''
title_str = f', {_md_escape(title)}' if title and title != 'None' else ''
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(
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\\.\\.',
parse_mode=ParseMode.MARKDOWN_V2)
# Run scan asynchronously (avoid blocking the event loop)
# Use canonical FIDE ID from profile page (may differ from URL-extracted ID)
from concurrent.futures import ThreadPoolExecutor
loop = asyncio.get_event_loop()
tournaments = await loop.run_in_executor(
None, tracker.scan_for_player, player['fide_id'])
# SQL-only cache search (instant, no HTTP). Background jobs handle HTTP discovery.
tournaments = tracker.find_player_in_cache(player['fide_id'])
if tournaments:
# Auto-subscribe to all found tournaments
@ -445,8 +467,8 @@ async def _lookup_and_confirm(update: Update, context, fide_id: int):
dates = ''
if t['start_date'] and t['end_date']:
dates = f' \\({_md_escape(t["start_date"])}{_md_escape(t["end_date"])}\\)'
safe_tour = _md_escape(t['name'])
lines.append(f'{safe_tour}{dates}')
tour_link = f'[{_md_escape(t["name"])}]({t["url"]})'
lines.append(f'{tour_link}{dates}')
lines.append('\nЯ буду присылать результаты и жеребьёвку\.\n'
'/myplayers \\- список подписок')
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_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
@ -121,12 +123,22 @@ def fetch_fide_player(fide_id: int) -> dict:
if m:
fed = m.group(1)
# Rating (standard): <p>2841</p><p ...>STANDARD
# 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)
@ -142,6 +154,7 @@ def fetch_fide_player(fide_id: int) -> dict:
return {
'fide_id': fide_id, 'name': name, 'rating': rating,
'rapid': rapid, 'blitz': blitz,
'fed': fed, 'title': title,
}
@ -156,13 +169,19 @@ def add_player_watch(user_id: int, player: dict, lang: str):
"""
conn = _get_conn()
conn.execute('''
INSERT OR IGNORE INTO subscriptions
(user_id, fide_id, player_name, rating, fed, lang, tournament_url,
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)
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('fed', ''), lang))
player.get('rapid', 0), player.get('blitz', 0), player.get('fed', ''), lang))
conn.commit()
conn.close()
@ -174,12 +193,13 @@ def add_subscription(user_id: int, player: dict, tournament_url: str,
conn = _get_conn()
conn.execute('''
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,
last_round_done, start_date, end_date, active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, 1)
''', (user_id, player['fide_id'], player['name'], player['rating'],
player.get('fed', ''), lang, tournament_url, tournament_name,
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()
@ -189,7 +209,7 @@ 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, fed, tournament_name, '
'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()
@ -197,20 +217,21 @@ def get_user_subs(user_id: int) -> list:
players: dict = {}
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:
players[fide_id] = {
'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': [],
}
if url == 'fide_only':
players[fide_id]['tournaments'].append({
'name': None, 'points': pts, 'round_done': rd,
'name': None, 'url': None, 'points': pts, 'round_done': rd,
})
else:
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())
@ -578,27 +599,16 @@ def discover_max_tnr() -> int:
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 full tnr_cache via SQL (single query, instant). Return if found.
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',
'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()}
@ -619,6 +629,23 @@ def scan_for_player(fide_id: int, max_tnr: int = None,
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: