From e10aa85e6f0aba7e0a6a7c1deee40fe299c83628 Mon Sep 17 00:00:00 2001 From: Danil Date: Tue, 19 May 2026 23:03:53 +0300 Subject: [PATCH] Replace sport betting with Liquipedia Dota 2 betting --- games/betting.py | 387 +++++++++++++++----------------------- webapp/frontend/js/app.js | 2 + 2 files changed, 156 insertions(+), 233 deletions(-) diff --git a/games/betting.py b/games/betting.py index 658060d..5863acb 100644 --- a/games/betting.py +++ b/games/betting.py @@ -1,11 +1,17 @@ import logging import time as _time +import hashlib +import random +import urllib.request +import json +import gzip from datetime import datetime from zoneinfo import ZoneInfo +from bs4 import BeautifulSoup -import aiohttp import psycopg2 import psycopg2.extras +import aiohttp import config from db import get_conn @@ -19,6 +25,7 @@ _last_viewed_sport_by_user: dict[int, str] = {} _DRAW_NAMES = {"draw", "tie", "ничья"} BET_HISTORY_DAYS = 30 +_LIQUIPEDIA_CACHE_TTL = 300 # 5 minutes cache to respect Liquipedia rate limits def _init_bets_db() -> None: @@ -68,91 +75,119 @@ def get_user_sport_context(user_id: int) -> str | None: async def _fetch_available_sports() -> list[dict]: - global _sports_cache + return [{"key": "dota2", "title": "🎮 Dota 2", "active": True}] + + +async def fetch_liquipedia_matches() -> list[dict]: + global _matches_cache now = _time.time() - if _sports_cache and (now - _sports_cache[0]) < 3600: - return _sports_cache[1] - - if not config.ODDS_API_KEY: - return [] - - url = f"{config.ODDS_API_BASE}/sports" - params = {"apiKey": config.ODDS_API_KEY} - - try: - async with aiohttp.ClientSession() as session: - async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=15)) as resp: - if resp.status != 200: - body = await resp.text() - logger.warning(f"Sports API returned {resp.status}: {body[:200]}") - return [] - data = await resp.json() - _sports_cache = (now, data) - return data - except Exception: - logger.exception("Failed to fetch sports list") - return [] - - -async def fetch_matches(sport_key: str) -> list[dict]: - now = _time.time() - cached = _matches_cache.get(sport_key) - if cached and (now - cached[0]) < config.ODDS_CACHE_TTL: + cached = _matches_cache.get("liquipedia_all") + if cached and (now - cached[0]) < _LIQUIPEDIA_CACHE_TTL: return cached[1] - if not config.ODDS_API_KEY: - logger.warning("ODDS_API_KEY не задан, матчи недоступны") - return [] - - url = f"{config.ODDS_API_BASE}/sports/{sport_key}/odds" - params = { - "apiKey": config.ODDS_API_KEY, - "regions": "eu", - "markets": "h2h", - "oddsFormat": "decimal", + url = "https://liquipedia.net/dota2/api.php?action=parse&page=Liquipedia:Matches&format=json" + headers = { + "User-Agent": "Dota2BettingTelegramBot/1.0 (contact: danil@example.com)", + "Accept-Encoding": "gzip" } try: async with aiohttp.ClientSession() as session: - async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=15)) as resp: + async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=15)) as resp: if resp.status != 200: - body = await resp.text() - logger.warning(f"Odds API returned {resp.status} for {sport_key}: {body[:300]}") + logger.warning(f"Liquipedia API returned {resp.status}") return cached[1] if cached else [] + data = await resp.json() - logger.info(f"Odds API for {sport_key}: received {len(data)} events") + html_content = data['parse']['text']['*'] + + soup = BeautifulSoup(html_content, "lxml") + match_divs = soup.find_all("div", class_="match-info") + + matches = [] + for idx, div in enumerate(match_divs): + # Tournament + tournament_div = div.find("div", class_="match-info-tournament") + tournament = tournament_div.get_text(strip=True) if tournament_div else "Unknown Tournament" + + # Teams + opponents = div.find_all("div", class_="match-info-header-opponent") + team1 = "TBD" + team2 = "TBD" + winner = None + completed = False + + if len(opponents) >= 2: + o1, o2 = opponents[0], opponents[1] + team1 = o1.get_text(strip=True) + team2 = o2.get_text(strip=True) + + c1 = o1.get("class", []) + c2 = o2.get("class", []) + + has_win_loss = any("winner" in c or "loser" in c for c in c1 + c2) + if has_win_loss: + completed = True + if any("winner" in c for c in c1): + winner = team1 + elif any("winner" in c for c in c2): + winner = team2 + else: + teams = div.find_all("span", class_="block-team") + if len(teams) >= 2: + team1 = teams[0].get_text(strip=True) + team2 = teams[1].get_text(strip=True) + + # Start time + timer_span = div.find("span", class_="timer-object") + timestamp = "" + if timer_span: + timestamp = timer_span.get("data-timestamp", "") + + if not team1 or not team2 or team1 == "TBD" or team2 == "TBD": + continue + + # ID generation + match_id = hashlib.md5(f"lp_{team1}_{team2}_{timestamp}".encode('utf-8')).hexdigest() + + # Deterministic Odds + seed = int(hashlib.md5(match_id.encode('utf-8')).hexdigest(), 16) % 10**8 + r = random.Random(seed) + odds_home = round(r.uniform(1.3, 3.2), 2) + margin = 1.08 + prob_home = 1.0 / odds_home + prob_away = margin - prob_home + if prob_away < 0.1: + prob_away = 0.1 + odds_away = round(1.0 / prob_away, 2) + + matches.append({ + "id": match_id, + "home": team1, + "away": team2, + "commence": datetime.fromtimestamp(int(timestamp)).isoformat() + "Z" if timestamp else "", + "timestamp": timestamp, + "completed": completed, + "winner": winner, + "odds": { + team1: odds_home, + team2: odds_away + }, + "tournament": tournament + }) + + _matches_cache["liquipedia_all"] = (now, matches) + logger.info(f"Scraped and cached {len(matches)} matches from Liquipedia") + return matches except Exception: - logger.exception(f"Failed to fetch odds for {sport_key}") + logger.exception("Failed to fetch matches from Liquipedia") return cached[1] if cached else [] - if not isinstance(data, list): - logger.warning(f"Odds API returned non-list for {sport_key}: {type(data)}") - return cached[1] if cached else [] - matches = [] - for event in data: - best_odds = {} - for bookmaker in event.get("bookmakers", []): - for market in bookmaker.get("markets", []): - if market.get("key") == "h2h": - for outcome in market.get("outcomes", []): - name = outcome.get("name", "") - price = outcome.get("price", 0) - if name and price and (name not in best_odds or price > best_odds[name]): - best_odds[name] = price - - if len(best_odds) >= 2: - matches.append({ - "id": event.get("id", ""), - "home": event.get("home_team", ""), - "away": event.get("away_team", ""), - "commence": event.get("commence_time", ""), - "odds": best_odds, - }) - - _matches_cache[sport_key] = (now, matches) - logger.info(f"Parsed {len(matches)} matches for {sport_key}") - return matches +async def fetch_matches(sport_key: str = "esports_dota2") -> list[dict]: + all_matches = await fetch_liquipedia_matches() + # Return only active (not completed) matches for betting + return [m for m in all_matches if not m["completed"]] def _get_match_outcomes(match: dict) -> list[dict[str, str | float]]: @@ -168,20 +203,7 @@ def _get_match_outcomes(match: dict) -> list[dict[str, str | float]]: home = match.get("home", "") away = match.get("away", "") append_outcome("1", home) - - draw_name = next((name for name in odds if name.casefold() in _DRAW_NAMES), None) - if draw_name: - append_outcome("X", draw_name) - append_outcome("2", away) - - next_code = 3 - for name in odds: - if name in added: - continue - append_outcome(str(next_code), name) - next_code += 1 - return outcomes @@ -204,11 +226,6 @@ def resolve_match_outcome(match: dict, raw_choice: str) -> str | None: if normalized == str(item["code"]).casefold(): return str(item["name"]) - if normalized in _DRAW_NAMES or normalized == "x": - for item in outcomes: - if str(item["code"]).casefold() == "x": - return str(item["name"]) - for item in outcomes: if normalized == str(item["name"]).casefold(): return str(item["name"]) @@ -254,54 +271,16 @@ def format_matches(matches: list[dict], sport_alias: str, sport_label: str) -> s def _get_sport_label(key: str) -> str: - labels = { - "dota2": "🎮 Dota 2", - } - return labels.get(key, key) + return "🎮 Dota 2" async def get_formatted_matches(sport_alias: str | None) -> str: - if not config.ODDS_API_KEY: - return "⚠️ ODDS_API_KEY не задан. Ставки недоступны." - - if sport_alias and sport_alias in config.ODDS_SPORTS: - sport_key = config.ODDS_SPORTS[sport_alias] - matches = await fetch_matches(sport_key) - if not matches: - sports = await _fetch_available_sports() - valid_keys = {s.get("key") for s in sports} - if sport_key not in valid_keys: - suggestions = [s for s in sports if sport_alias in s.get("key", "").lower() - or sport_alias in s.get("title", "").lower()] - if suggestions: - suggest_text = "\n".join( - f" • {s['key']} — {s.get('title', '')}" - for s in suggestions[:5] - ) - return ( - f"⚠️ Ключ {sport_key} не найден в API.\n" - f"Похожие виды спорта:\n{suggest_text}\n\n" - f"Обнови sport keys в config.py" - ) - return f"⚠️ Ключ {sport_key} не найден в API. Проверь ODDS_SPORTS в config.py" - return format_matches(matches, sport_alias, _get_sport_label(sport_alias)) - - parts = [] - for alias, sport_key in config.ODDS_SPORTS.items(): - matches = await fetch_matches(sport_key) - parts.append(format_matches(matches, alias, _get_sport_label(alias))) - - result = "\n\n".join(parts) - if all("матчей не найдено" in p for p in parts): - result += "\n\n💡 Если матчей нет — возможно, sport keys устарели. Проверь /sports_debug" - return result + matches = await fetch_matches() + return format_matches(matches, "dota2", "🎮 Dota 2") async def get_match_by_index(sport_alias: str, index: int) -> dict | None: - sport_key = config.ODDS_SPORTS.get(sport_alias) - if not sport_key: - return None - matches = await fetch_matches(sport_key) + matches = await fetch_matches() if 0 < index <= len(matches): return matches[index - 1] return None @@ -488,7 +467,7 @@ async def settle_bets() -> list[str]: try: with get_conn() as conn: cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) - cur.execute("SELECT DISTINCT match_id, sport FROM bets WHERE status = 'pending'") + cur.execute("SELECT DISTINCT match_id FROM bets WHERE status = 'pending'") pending = cur.fetchall() except psycopg2.Error: return notifications @@ -496,118 +475,60 @@ async def settle_bets() -> list[str]: if not pending: return notifications + all_matches = await fetch_liquipedia_matches() + match_map = {m["id"]: m for m in all_matches} now_ts = int(_time.time()) for row in pending: match_id = row["match_id"] - sport = row["sport"] - sport_key = config.ODDS_SPORTS.get(sport, sport) - - try: - url = f"{config.ODDS_API_BASE}/sports/{sport_key}/scores" - params = {"apiKey": config.ODDS_API_KEY, "daysFrom": 3} - async with aiohttp.ClientSession() as session: - async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=15)) as resp: - if resp.status != 200: - logger.warning(f"Scores API returned {resp.status} for {sport_key}") - continue - scores_data = await resp.json() - except Exception: - logger.exception(f"Failed to fetch scores for {sport_key}") + event = match_map.get(match_id) + if not event or not event.get("completed"): continue - for event in scores_data: - if event.get("id") != match_id or not event.get("completed"): - continue + winner = event.get("winner") + if not winner: + continue - scores = event.get("scores") - if not scores: - continue + try: + with get_conn() as conn: + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + "SELECT * FROM bets WHERE match_id = %s AND status = 'pending'", + (match_id,), + ) + bets = cur.fetchall() - score_values = [(s.get("name", ""), int(s.get("score", 0))) for s in scores] - winner = None - is_draw = False + wcur = conn.cursor() + for bet in bets: + chosen = bet["chosen_team"] + bet_won = (chosen == winner) - if len(score_values) >= 2 and score_values[0][1] == score_values[1][1]: - is_draw = True - winner = "Draw" - else: - max_score = -1 - for name, score_val in score_values: - if score_val > max_score: - max_score = score_val - winner = name - - if not winner: - continue - - try: - with get_conn() as conn: - cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) - cur.execute( - "SELECT * FROM bets WHERE match_id = %s AND status = 'pending'", - (match_id,), - ) - bets = cur.fetchall() - - wcur = conn.cursor() - for bet in bets: - chosen = bet["chosen_team"] - if is_draw: - bet_won = chosen.casefold() in _DRAW_NAMES or chosen == "Draw" - else: - bet_won = chosen == winner - - if bet_won: - winnings = round(bet["amount"] * bet["odds"], 1) - update_user_length(bet["user_id"], winnings) - wcur.execute( - "UPDATE bets SET status = 'won', payout = %s, resolved_ts = %s WHERE id = %s", - (winnings, now_ts, bet["id"]), - ) - notifications.append( - f"🎉 user_id={bet['user_id']}: выиграл {winnings:.1f} см " - f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})" - ) - else: - wcur.execute( - "UPDATE bets SET status = 'lost', payout = 0, resolved_ts = %s WHERE id = %s", - (now_ts, bet["id"]), - ) - notifications.append( - f"❌ user_id={bet['user_id']}: проиграл {bet['amount']:.1f} см " - f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})" - ) - except psycopg2.Error: - logger.exception("Failed to settle bets") + if bet_won: + winnings = round(bet["amount"] * bet["odds"], 1) + update_user_length(bet["user_id"], winnings) + wcur.execute( + "UPDATE bets SET status = 'won', payout = %s, resolved_ts = %s WHERE id = %s", + (winnings, now_ts, bet["id"]), + ) + notifications.append( + f"🎉 user_id={bet['user_id']}: выиграл {winnings:.1f} см " + f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})" + ) + else: + wcur.execute( + "UPDATE bets SET status = 'lost', payout = 0, resolved_ts = %s WHERE id = %s", + (now_ts, bet["id"]), + ) + notifications.append( + f"❌ user_id={bet['user_id']}: проиграл {bet['amount']:.1f} см " + f"({bet['home_team']} vs {bet['away_team']}, {bet['chosen_team']})" + ) + except psycopg2.Error: + logger.exception("Failed to settle bets") cleanup_old_bets() return notifications async def debug_sports() -> str: - if not config.ODDS_API_KEY: - return "⚠️ ODDS_API_KEY не задан." - - sports = await _fetch_available_sports() - if not sports: - return "❌ Не удалось получить список спортов от API." - - current_keys = set(config.ODDS_SPORTS.values()) - valid_keys = {s.get("key") for s in sports} - - lines = ["🔍 Диагностика sport keys:\n"] - for alias, key in config.ODDS_SPORTS.items(): - status = "✅" if key in valid_keys else "❌ НЕ НАЙДЕН" - lines.append(f" {alias} → {key} {status}") - - lines.append("\n📋 Доступные eSports и футбол:") - for s in sports: - key = s.get("key", "") - title = s.get("title", "") - active = s.get("active", False) - if active and ("esport" in key.lower() or "soccer" in key.lower() or "football" in key.lower()): - marker = " ← ИСПОЛЬЗУЕТСЯ" if key in current_keys else "" - lines.append(f" • {key} — {title}{marker}") - - return "\n".join(lines) + return "🎮 Dota 2 — Scraper Liquipedia активен. Odds API отключен." diff --git a/webapp/frontend/js/app.js b/webapp/frontend/js/app.js index f3ee499..1b26b73 100644 --- a/webapp/frontend/js/app.js +++ b/webapp/frontend/js/app.js @@ -372,10 +372,12 @@ async function renderMatches(league, leagueName, isOnlyLeague = false) { П1 ${m.odds_home?.toFixed(2) || '—'} + ${m.odds_draw ? ` + ` : ''}