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 ? `
+ ` : ''}