Compare commits
No commits in common. "d01c6aab85d6a772839190a72739ed3966a43d68" and "aa892e17fbaf037cecaa05066c1c136a9aab5ca4" have entirely different histories.
d01c6aab85
...
aa892e17fb
42
CHANGELOG.md
42
CHANGELOG.md
@ -1,43 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## v4.3 - 2025-09-13 - 修复标题多重比分问题
|
||||
- **🐛 修复多重比分累积问题**:
|
||||
- 修复了 `update_event_with_score()` 和 `update_event_with_result()` 方法中的比分清理逻辑
|
||||
- 原问题:正则表达式 `^(\d+[-:]\d+\s+)+` 无法匹配带有 `✓` 标记的标题,导致比分不断累积
|
||||
- 现在使用 `\d+-\d+\s+` 清理所有比分,不限于开头位置
|
||||
- 修复了9个历史事件的标题(如 "✓ 2-0 1-0" 改为 "✓ 2-0")
|
||||
|
||||
## v4.2 - 2025-09-13 - 智能合并重复比赛
|
||||
- **🔀 智能合并重复比赛**:
|
||||
- 新增 `_merge_duplicate_matches()` 方法
|
||||
- 自动检测同时间(30分钟窗口)具有共同队伍的比赛
|
||||
- 当发现两场比赛在同一时间且共享一个队伍时,认为是同一场比赛的不同表示
|
||||
- 例如:将 "Team Liquid vs OG" 和 "Team Liquid vs OG Esports" 合并为一场比赛
|
||||
- **📝 队伍名称标准化**:
|
||||
- 保留更长的队伍名称作为正式名称
|
||||
- 解决了队伍名称变体问题(如 "OG" vs "OG Esports")
|
||||
- 避免因队伍名称不一致导致的重复事件
|
||||
- **⚡ 优化处理流程**:
|
||||
- 在 `fetch_all_matches()` 后立即进行合并处理
|
||||
- 减少后续同步过程中的重复检查
|
||||
- 提高整体同步效率
|
||||
|
||||
## v4.1 - 2025-09-13 - 修复TBD事件重复问题
|
||||
- **🐛 修复TBD事件匹配逻辑**:
|
||||
- 修复了 `find_existing_event()` 方法中的TBD匹配逻辑
|
||||
- 原问题:只匹配 "TBD_TBD" 事件键,不匹配 "TBD_NGX" 或 "PV_TBD" 等部分TBD事件
|
||||
- 现在可以正确识别并更新所有包含TBD的事件("TBD vs Team" 和 "Team vs TBD")
|
||||
- 防止创建重复事件,确保TBD事件被更新而非创建新事件
|
||||
- **🔧 增强TBD事件清理**:
|
||||
- 修复了 `clean_duplicate_and_expired_events()` 方法的清理逻辑
|
||||
- 原问题:只删除 "TBD vs TBD" 格式的事件,导致部分TBD事件无法被清理
|
||||
- 现在删除所有与确认比赛同时间的TBD事件,不再限于特定格式
|
||||
- 成功清理了9月13日的3个重复TBD事件
|
||||
- **🎯 问题根源分析**:
|
||||
- 发现问题:同一时间存在 "TBD vs NGX" 和 "XG vs NGX" 重复事件
|
||||
- 根本原因:TBD事件没有被正确更新,而是创建了新的确认事件
|
||||
- 解决方案:改进事件匹配算法,支持部分TBD匹配和时间窗口匹配
|
||||
|
||||
## v4.0 - 2025-09-12 - 代码结构重构与错误处理优化
|
||||
- **🏗️ 代码结构重构**:
|
||||
- 使用 `@dataclass` 替代字典存储比赛数据,提高类型安全性
|
||||
@ -218,12 +180,10 @@
|
||||
| v3.8 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| v3.9 | ✓ | ✓ | ✓ | ✓ | ✓+ | ✓ |
|
||||
| v4.0 | ✓ | ✓ | ✓ | ✓ | ✓+ | ✓ |
|
||||
| v4.1 | ✓ | ✓ | ✓ | ✓ | ✓++ | ✓ |
|
||||
| v4.2 | ✓ | ✓ | ✓ | ✓ | ✓++ | ✓ |
|
||||
|
||||
## 使用建议
|
||||
|
||||
推荐使用最新的 v4.2 版本,它包含智能合并重复比赛功能并提供更好的错误处理:
|
||||
推荐使用最新的 v4.0 版本,它包含所有功能并提供更好的错误处理:
|
||||
```bash
|
||||
python sync_dota2_matches.py --calendar-id "YOUR_CALENDAR_ID"
|
||||
```
|
||||
|
||||
21
README.md
21
README.md
@ -1,25 +1,8 @@
|
||||
# Dota 2 Calendar Sync v4.3
|
||||
# Dota 2 Calendar Sync v4.0
|
||||
|
||||
自动从 Liquipedia 获取 Dota 2 Tier 1 比赛信息并同步到 Google Calendar,支持自动更新比赛结果、时间变更、智能管理TBD占位事件、自动清理过期和重复比赛。
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v4.3 (2025-09-13)
|
||||
- 🐛 修复多重比分累积:解决了标题中出现多个比分的问题(如 "✓ 2-0 1-0")
|
||||
- 🔧 改进比分清理逻辑:现在能正确清理所有位置的比分,不只是开头
|
||||
- ✅ 修复了9个历史事件的标题格式
|
||||
|
||||
### v4.2 (2025-09-13)
|
||||
- 🔀 智能合并重复比赛:自动检测同时间具有共同队伍的比赛并合并(处理队伍名称变体)
|
||||
- 📝 队伍名称标准化:当发现 "OG" 和 "OG Esports" 这样的变体时,保留更长的完整名称
|
||||
- ⚡ 优化数据获取:在获取数据时即进行去重,减少后续处理的复杂度
|
||||
|
||||
### v4.1 (2025-09-13)
|
||||
- 🐛 修复TBD事件匹配逻辑:现在可以正确识别并更新部分TBD事件(如 "TBD vs Team" 或 "Team vs TBD")
|
||||
- 🔧 增强TBD事件清理:删除所有与确认比赛同时间的TBD事件,不再限于 "TBD vs TBD" 格式
|
||||
- 🎯 防止创建重复事件:改进事件匹配算法,确保TBD事件被更新而非创建新事件
|
||||
|
||||
### v4.0 (2025-09-05)
|
||||
**v4.0 新特性**:
|
||||
- 🏗️ 代码结构重构:使用 dataclass 替代字典,提高类型安全性
|
||||
- 🔄 增强错误处理:添加自动重试机制(指数退避)
|
||||
- 📝 专业日志系统:使用 Python logging 模块,支持多级别日志
|
||||
|
||||
@ -44,20 +44,7 @@ class MatchFormat(Enum):
|
||||
|
||||
@dataclass
|
||||
class Match:
|
||||
"""Data class for match information
|
||||
|
||||
Attributes:
|
||||
id: Unique identifier for the match (MD5 hash)
|
||||
team1: Name of the first team
|
||||
team2: Name of the second team
|
||||
datetime: Match scheduled datetime (timezone aware)
|
||||
tournament: Tournament name (e.g., 'The International 2025')
|
||||
format: Match format (Bo1, Bo3, Bo5)
|
||||
score: Current or final score (e.g., '2-1')
|
||||
completed: Whether the series is completed
|
||||
has_score: Whether the match has any score (including partial)
|
||||
winner: Name of the winning team if completed
|
||||
"""
|
||||
"""Data class for match information"""
|
||||
id: str
|
||||
team1: str
|
||||
team2: str
|
||||
@ -70,20 +57,12 @@ class Match:
|
||||
winner: Optional[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
"""Post-initialization processing
|
||||
|
||||
Ensures datetime is always timezone aware (UTC)
|
||||
"""
|
||||
"""Ensure datetime is timezone aware"""
|
||||
if self.datetime and self.datetime.tzinfo is None:
|
||||
self.datetime = self.datetime.replace(tzinfo=pytz.UTC)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert Match object to dictionary
|
||||
|
||||
Returns:
|
||||
Dict containing all match attributes
|
||||
Used for backward compatibility with existing code
|
||||
"""
|
||||
"""Convert to dictionary for compatibility"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'team1': self.team1,
|
||||
@ -98,21 +77,7 @@ class Match:
|
||||
}
|
||||
|
||||
def retry_on_exception(max_retries: int = 3, delay: float = 1.0, backoff: float = 2.0):
|
||||
"""Decorator for retrying functions with exponential backoff
|
||||
|
||||
Args:
|
||||
max_retries: Maximum number of retry attempts
|
||||
delay: Initial delay between retries in seconds
|
||||
backoff: Multiplier for delay after each retry (exponential backoff)
|
||||
|
||||
Returns:
|
||||
Decorated function that automatically retries on exceptions
|
||||
|
||||
Example:
|
||||
@retry_on_exception(max_retries=3, delay=2.0)
|
||||
def fetch_data():
|
||||
# This will retry up to 3 times with delays of 2s, 4s, 8s
|
||||
"""
|
||||
"""Decorator for retrying functions with exponential backoff"""
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
@ -136,32 +101,13 @@ def retry_on_exception(max_retries: int = 3, delay: float = 1.0, backoff: float
|
||||
return decorator
|
||||
|
||||
class Dota2CalendarSync:
|
||||
"""Main class for synchronizing Dota 2 matches to Google Calendar
|
||||
|
||||
Handles fetching matches from Liquipedia and syncing them to Google Calendar,
|
||||
including creating new events, updating scores, and managing TBD placeholders.
|
||||
"""
|
||||
|
||||
def __init__(self, credentials_file='credentials.json', calendar_id='primary'):
|
||||
"""Initialize the sync service
|
||||
|
||||
Args:
|
||||
credentials_file: Path to Google service account credentials JSON
|
||||
calendar_id: Google Calendar ID or 'primary' for main calendar
|
||||
"""
|
||||
self.credentials_file = credentials_file
|
||||
self.calendar_id = calendar_id
|
||||
self.service = self._authenticate()
|
||||
|
||||
def _authenticate(self):
|
||||
"""Authenticate with Google Calendar using service account credentials
|
||||
|
||||
Returns:
|
||||
Google Calendar service object
|
||||
|
||||
Raises:
|
||||
SystemExit: If authentication fails
|
||||
"""
|
||||
"""Authenticate with Google Calendar using service account credentials"""
|
||||
try:
|
||||
credentials = service_account.Credentials.from_service_account_file(
|
||||
self.credentials_file,
|
||||
@ -176,14 +122,7 @@ class Dota2CalendarSync:
|
||||
|
||||
@retry_on_exception(max_retries=3, delay=2.0)
|
||||
def fetch_all_matches(self) -> Tuple[List[Match], List[Match]]:
|
||||
"""Fetch both upcoming and completed matches from Liquipedia
|
||||
|
||||
Scrapes the Liquipedia matches page for Tier 1 Dota 2 tournaments.
|
||||
Automatically retries on network failures.
|
||||
|
||||
Returns:
|
||||
Tuple of (upcoming_matches, completed_matches) lists
|
||||
"""
|
||||
"""Fetch both upcoming and completed matches from Liquipedia"""
|
||||
url = 'https://liquipedia.net/dota2/Liquipedia:Matches'
|
||||
headers = {
|
||||
'User-Agent': 'Dota2CalendarSync/3.0 (https://github.com/youruser/dota2-calendar)'
|
||||
@ -229,10 +168,6 @@ class Dota2CalendarSync:
|
||||
logger.info(f"✓ Found {len(upcoming)} upcoming matches")
|
||||
logger.info(f"✓ Found {len(completed)} completed matches with results")
|
||||
|
||||
# Merge duplicate matches at the same time
|
||||
upcoming = self._merge_duplicate_matches(upcoming)
|
||||
completed = self._merge_duplicate_matches(completed)
|
||||
|
||||
return upcoming, completed
|
||||
|
||||
except requests.RequestException as e:
|
||||
@ -240,18 +175,7 @@ class Dota2CalendarSync:
|
||||
return [], []
|
||||
|
||||
def _parse_match(self, parent, timestamp_elem) -> Optional[Match]:
|
||||
"""Parse match data from an HTML element
|
||||
|
||||
Extracts team names, scores, format, and tournament information
|
||||
from Liquipedia's HTML structure.
|
||||
|
||||
Args:
|
||||
parent: Parent div element containing match information
|
||||
timestamp_elem: Span element with data-timestamp attribute
|
||||
|
||||
Returns:
|
||||
Match object if parsing successful, None otherwise
|
||||
"""
|
||||
"""Parse match data from an element using HTML structure"""
|
||||
try:
|
||||
# Get timestamp
|
||||
timestamp = timestamp_elem.get('data-timestamp')
|
||||
@ -343,17 +267,7 @@ class Dota2CalendarSync:
|
||||
return None
|
||||
|
||||
def _extract_score(self, parent) -> Optional[str]:
|
||||
"""Extract score from match element
|
||||
|
||||
Looks for score in structured HTML elements first,
|
||||
then falls back to text pattern matching.
|
||||
|
||||
Args:
|
||||
parent: Parent element containing match information
|
||||
|
||||
Returns:
|
||||
Score string (e.g., '2-1') or None if no valid score found
|
||||
"""
|
||||
"""Extract score from match element"""
|
||||
# Look for score in structured elements
|
||||
score_holder = parent.find('div', class_='match-info-header-scoreholder')
|
||||
if score_holder:
|
||||
@ -381,14 +295,7 @@ class Dota2CalendarSync:
|
||||
return None
|
||||
|
||||
def _extract_format(self, parent) -> Optional[str]:
|
||||
"""Extract match format (Bo1, Bo3, Bo5)
|
||||
|
||||
Args:
|
||||
parent: Parent element containing match information
|
||||
|
||||
Returns:
|
||||
Format string (e.g., 'Bo3') or None if not found
|
||||
"""
|
||||
"""Extract match format"""
|
||||
format_elem = parent.find('span', class_='match-info-header-scoreholder-lower')
|
||||
if format_elem:
|
||||
format_text = format_elem.get_text().strip()
|
||||
@ -405,16 +312,7 @@ class Dota2CalendarSync:
|
||||
return None
|
||||
|
||||
def _extract_tournament(self, parent) -> Optional[str]:
|
||||
"""Extract tournament name from match element
|
||||
|
||||
Special handling for TI2025 and Major tournaments.
|
||||
|
||||
Args:
|
||||
parent: Parent element containing match information
|
||||
|
||||
Returns:
|
||||
Tournament name or None if not found
|
||||
"""
|
||||
"""Extract tournament name"""
|
||||
tournament_elem = parent.find('div', class_='match-info-tournament')
|
||||
if tournament_elem:
|
||||
tournament_text = tournament_elem.get_text().strip()
|
||||
@ -443,16 +341,7 @@ class Dota2CalendarSync:
|
||||
return None
|
||||
|
||||
def _is_series_completed(self, score1: int, score2: int, format_str: Optional[str]) -> bool:
|
||||
"""Check if a series is completed based on score and format
|
||||
|
||||
Args:
|
||||
score1: Score for team 1
|
||||
score2: Score for team 2
|
||||
format_str: Match format (Bo1, Bo3, Bo5)
|
||||
|
||||
Returns:
|
||||
True if series is completed (someone has won enough games)
|
||||
"""
|
||||
"""Check if a series is completed based on score and format"""
|
||||
if not format_str:
|
||||
return score1 >= 2 or score2 >= 2
|
||||
|
||||
@ -466,135 +355,15 @@ class Dota2CalendarSync:
|
||||
return True
|
||||
|
||||
def _clean_team_name(self, name: str) -> str:
|
||||
"""Clean and normalize team name
|
||||
|
||||
Removes extra whitespace, parenthetical notes, dates, etc.
|
||||
|
||||
Args:
|
||||
name: Raw team name from HTML
|
||||
|
||||
Returns:
|
||||
Cleaned team name
|
||||
"""
|
||||
"""Clean and normalize team name"""
|
||||
name = re.sub(r'\s+', ' ', name).strip()
|
||||
name = re.sub(r'\s*\(.*?\)\s*$', '', name)
|
||||
name = re.sub(r'^\d{4}-\d{2}-\d{2}.*', '', name).strip()
|
||||
name = re.sub(r'^\w+\s+\d+,\s+\d{4}.*', '', name).strip()
|
||||
return name
|
||||
|
||||
def _merge_duplicate_matches(self, matches: List[Match]) -> List[Match]:
|
||||
"""Merge duplicate matches at the same time with one common team
|
||||
|
||||
When multiple matches occur at the same time with one common team,
|
||||
they likely represent the same match with different team name variations.
|
||||
This method merges such duplicates, keeping the longer/more complete team name.
|
||||
|
||||
Args:
|
||||
matches: List of Match objects to check for duplicates
|
||||
|
||||
Returns:
|
||||
List of Match objects with duplicates merged
|
||||
"""
|
||||
if not matches:
|
||||
return matches
|
||||
|
||||
# Group matches by time (30-minute window)
|
||||
from collections import defaultdict
|
||||
matches_by_time = defaultdict(list)
|
||||
|
||||
for match in matches:
|
||||
# Create time key with 30-minute precision
|
||||
time_key = match.datetime.replace(minute=(match.datetime.minute // 30) * 30, second=0, microsecond=0)
|
||||
matches_by_time[time_key].append(match)
|
||||
|
||||
merged_matches = []
|
||||
|
||||
for time_key, time_matches in matches_by_time.items():
|
||||
if len(time_matches) <= 1:
|
||||
# No duplicates at this time
|
||||
merged_matches.extend(time_matches)
|
||||
continue
|
||||
|
||||
# Check for matches with common teams
|
||||
processed = set()
|
||||
|
||||
for i, match1 in enumerate(time_matches):
|
||||
if i in processed:
|
||||
continue
|
||||
|
||||
merged = False
|
||||
for j, match2 in enumerate(time_matches[i+1:], i+1):
|
||||
if j in processed:
|
||||
continue
|
||||
|
||||
# Check if matches share a common team
|
||||
common_team = None
|
||||
different_teams = []
|
||||
|
||||
if match1.team1 == match2.team1:
|
||||
common_team = match1.team1
|
||||
different_teams = [match1.team2, match2.team2]
|
||||
elif match1.team1 == match2.team2:
|
||||
common_team = match1.team1
|
||||
different_teams = [match1.team2, match2.team1]
|
||||
elif match1.team2 == match2.team1:
|
||||
common_team = match1.team2
|
||||
different_teams = [match1.team1, match2.team2]
|
||||
elif match1.team2 == match2.team2:
|
||||
common_team = match1.team2
|
||||
different_teams = [match1.team1, match2.team1]
|
||||
|
||||
if common_team and common_team != 'TBD':
|
||||
# Found matches with a common non-TBD team
|
||||
# Keep the longer/more complete team name for the different team
|
||||
chosen_different = max(different_teams, key=len)
|
||||
|
||||
# Create merged match
|
||||
merged_match = Match(
|
||||
id=match1.id, # Keep first match's ID
|
||||
team1=common_team if match1.team1 == common_team else chosen_different,
|
||||
team2=chosen_different if match1.team1 == common_team else common_team,
|
||||
datetime=match1.datetime,
|
||||
tournament=match1.tournament or match2.tournament,
|
||||
format=match1.format or match2.format,
|
||||
score=match1.score or match2.score,
|
||||
completed=match1.completed or match2.completed,
|
||||
has_score=match1.has_score or match2.has_score,
|
||||
winner=match1.winner or match2.winner
|
||||
)
|
||||
|
||||
merged_matches.append(merged_match)
|
||||
processed.add(i)
|
||||
processed.add(j)
|
||||
merged = True
|
||||
|
||||
logger.info(f"Merged duplicate matches at {time_key}: "
|
||||
f"{match1.team1} vs {match1.team2} + {match2.team1} vs {match2.team2} "
|
||||
f"-> {merged_match.team1} vs {merged_match.team2}")
|
||||
break
|
||||
|
||||
if not merged and i not in processed:
|
||||
# No merge found for this match
|
||||
merged_matches.append(match1)
|
||||
processed.add(i)
|
||||
|
||||
return merged_matches
|
||||
|
||||
def _generate_match_id(self, team1: str, team2: str, tournament: Optional[str], match_datetime: datetime) -> str:
|
||||
"""Generate a unique ID for a match
|
||||
|
||||
Uses MD5 hash of team names and tournament. For TBD vs TBD matches,
|
||||
includes datetime to ensure uniqueness.
|
||||
|
||||
Args:
|
||||
team1: First team name
|
||||
team2: Second team name
|
||||
tournament: Tournament name
|
||||
match_datetime: Match scheduled time
|
||||
|
||||
Returns:
|
||||
16-character hex string ID
|
||||
"""
|
||||
"""Generate a unique ID for a match"""
|
||||
id_parts = []
|
||||
|
||||
# For TBD vs TBD matches, include datetime to make them unique
|
||||
@ -614,14 +383,7 @@ class Dota2CalendarSync:
|
||||
return hashlib.md5(unique_string.encode()).hexdigest()[:16]
|
||||
|
||||
def _remove_duplicates(self, matches: List[Match]) -> List[Match]:
|
||||
"""Remove duplicate matches based on ID
|
||||
|
||||
Args:
|
||||
matches: List of Match objects
|
||||
|
||||
Returns:
|
||||
List with duplicates removed (keeps last occurrence)
|
||||
"""
|
||||
"""Remove duplicate matches based on ID"""
|
||||
unique_matches = {}
|
||||
for match in matches:
|
||||
unique_matches[match.id] = match
|
||||
@ -629,21 +391,7 @@ class Dota2CalendarSync:
|
||||
|
||||
@retry_on_exception(max_retries=3, delay=1.0)
|
||||
def get_existing_events(self, days_back=7, days_ahead=30) -> Dict[str, Any]:
|
||||
"""Get existing Dota 2 events from Google Calendar
|
||||
|
||||
Fetches events within the specified time range and builds two indexes:
|
||||
- By match ID (from event description)
|
||||
- By match details (teams + tournament)
|
||||
|
||||
Args:
|
||||
days_back: Number of days to look back
|
||||
days_ahead: Number of days to look ahead
|
||||
|
||||
Returns:
|
||||
Dictionary with event indexes:
|
||||
- Keys are match IDs
|
||||
- Special '_by_match' key contains team+tournament index
|
||||
"""
|
||||
"""Get existing Dota 2 events from Google Calendar"""
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
time_min = (now - timedelta(days=days_back)).isoformat() + 'Z'
|
||||
@ -719,21 +467,7 @@ class Dota2CalendarSync:
|
||||
raise
|
||||
|
||||
def find_existing_event(self, match: Match, existing_events: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Find existing calendar event for a match
|
||||
|
||||
Tries multiple matching strategies:
|
||||
1. Direct ID match
|
||||
2. Team + tournament match
|
||||
3. Teams only (for live score updates)
|
||||
4. TBD placeholder match (within 1 hour time window)
|
||||
|
||||
Args:
|
||||
match: Match object to find
|
||||
existing_events: Dictionary of existing calendar events
|
||||
|
||||
Returns:
|
||||
Calendar event dict if found, None otherwise
|
||||
"""
|
||||
"""Find existing calendar event for a match"""
|
||||
# Try by ID first
|
||||
if match.id in existing_events:
|
||||
return existing_events[match.id]
|
||||
@ -756,55 +490,21 @@ class Dota2CalendarSync:
|
||||
match.tournament and match.tournament in event_key):
|
||||
return event
|
||||
|
||||
# Special handling for TBD matches - check if this match should update a TBD placeholder
|
||||
# Special handling for TBD matches
|
||||
if not (match.team1 == 'TBD' and match.team2 == 'TBD'):
|
||||
for event_key, event in by_match.items():
|
||||
# Check if this is a TBD event that could be updated with real teams
|
||||
if 'TBD' in event_key and match.tournament and match.tournament in event_key:
|
||||
# Parse the event key to get teams
|
||||
key_parts = event_key.split('_')
|
||||
if len(key_parts) >= 3:
|
||||
existing_team1 = key_parts[0]
|
||||
existing_team2 = key_parts[1]
|
||||
|
||||
# Check if this TBD event matches the incoming match
|
||||
# Match if: one team is TBD and the other matches, or both are TBD
|
||||
teams_match = False
|
||||
if existing_team1 == 'TBD' and existing_team2 == 'TBD':
|
||||
# Both TBD - match by time
|
||||
teams_match = True
|
||||
elif existing_team1 == 'TBD' and existing_team2 == match.team2:
|
||||
# First team is TBD, second matches
|
||||
teams_match = True
|
||||
elif existing_team1 == match.team1 and existing_team2 == 'TBD':
|
||||
# First team matches, second is TBD
|
||||
teams_match = True
|
||||
elif existing_team2 == 'TBD' and existing_team1 == match.team1:
|
||||
# Reverse check
|
||||
teams_match = True
|
||||
elif existing_team1 == 'TBD' and existing_team2 == match.team1:
|
||||
# Reverse check
|
||||
teams_match = True
|
||||
|
||||
if teams_match:
|
||||
if 'TBD_TBD' in event_key and match.tournament and match.tournament in event_key:
|
||||
# Check if time matches (within 1 hour)
|
||||
event_start = event['start'].get('dateTime', event['start'].get('date'))
|
||||
event_dt = datetime.fromisoformat(event_start.replace('Z', '+00:00'))
|
||||
if abs((event_dt - match.datetime).total_seconds()) < 3600:
|
||||
logger.info(f"Found TBD match to update: {existing_team1} vs {existing_team2} -> {match.team1} vs {match.team2}")
|
||||
logger.info(f"Found TBD match to update: {match.team1} vs {match.team2}")
|
||||
return event
|
||||
|
||||
return None
|
||||
|
||||
def create_calendar_event(self, match: Match) -> Dict[str, Any]:
|
||||
"""Create a Google Calendar event for a match
|
||||
|
||||
Args:
|
||||
match: Match object with event details
|
||||
|
||||
Returns:
|
||||
Dictionary with Google Calendar event format
|
||||
"""
|
||||
"""Create a Google Calendar event for a match"""
|
||||
# Build summary
|
||||
if match.tournament:
|
||||
summary = f"{match.team1} vs {match.team2} [{match.tournament}]"
|
||||
@ -862,17 +562,7 @@ class Dota2CalendarSync:
|
||||
|
||||
@retry_on_exception(max_retries=2, delay=1.0)
|
||||
def update_event_time(self, event_id: str, new_datetime: datetime) -> bool:
|
||||
"""Update the time of an existing event
|
||||
|
||||
Preserves event duration when updating start time.
|
||||
|
||||
Args:
|
||||
event_id: Google Calendar event ID
|
||||
new_datetime: New start time for the event
|
||||
|
||||
Returns:
|
||||
True if update successful, False otherwise
|
||||
"""
|
||||
"""Update the time of an existing event"""
|
||||
try:
|
||||
# Get the existing event
|
||||
event = self.service.events().get(
|
||||
@ -913,17 +603,7 @@ class Dota2CalendarSync:
|
||||
|
||||
@retry_on_exception(max_retries=2, delay=1.0)
|
||||
def update_event_with_teams(self, event_id: str, match: Match) -> bool:
|
||||
"""Update a TBD event with actual team names
|
||||
|
||||
Used when TBD placeholder teams are determined.
|
||||
|
||||
Args:
|
||||
event_id: Google Calendar event ID
|
||||
match: Match object with updated team names
|
||||
|
||||
Returns:
|
||||
True if update successful, False otherwise
|
||||
"""
|
||||
"""Update a TBD event with actual team names"""
|
||||
try:
|
||||
# Get the existing event
|
||||
event = self.service.events().get(
|
||||
@ -968,17 +648,7 @@ class Dota2CalendarSync:
|
||||
|
||||
@retry_on_exception(max_retries=2, delay=1.0)
|
||||
def update_event_with_score(self, event_id: str, match: Match) -> bool:
|
||||
"""Update an existing calendar event with in-progress score
|
||||
|
||||
Shows current score for ongoing matches (not yet completed).
|
||||
|
||||
Args:
|
||||
event_id: Google Calendar event ID
|
||||
match: Match object with current score
|
||||
|
||||
Returns:
|
||||
True if update successful, False otherwise
|
||||
"""
|
||||
"""Update an existing calendar event with in-progress score"""
|
||||
try:
|
||||
# Get the existing event
|
||||
event = self.service.events().get(
|
||||
@ -998,13 +668,9 @@ class Dota2CalendarSync:
|
||||
else:
|
||||
description += f"\n{score_text}"
|
||||
|
||||
# Update summary - remove ALL existing scores first
|
||||
# Update summary
|
||||
summary = event.get('summary', '')
|
||||
# Remove all score patterns (including those after checkmark)
|
||||
summary = re.sub(r'\d+-\d+\s+', '', summary)
|
||||
# Clean up extra spaces
|
||||
summary = ' '.join(summary.split())
|
||||
# Add new score at the beginning
|
||||
summary = re.sub(r'^(\d+[-:]\d+\s+)+', '', summary)
|
||||
summary = f"{match.score} {summary}"
|
||||
|
||||
event['description'] = description
|
||||
@ -1025,17 +691,7 @@ class Dota2CalendarSync:
|
||||
|
||||
@retry_on_exception(max_retries=2, delay=1.0)
|
||||
def update_event_with_result(self, event_id: str, match: Match) -> bool:
|
||||
"""Update an existing calendar event with match results
|
||||
|
||||
Marks match as completed with final score and winner.
|
||||
|
||||
Args:
|
||||
event_id: Google Calendar event ID
|
||||
match: Match object with final results
|
||||
|
||||
Returns:
|
||||
True if update successful, False otherwise
|
||||
"""
|
||||
"""Update an existing calendar event with match results"""
|
||||
try:
|
||||
# Get the existing event
|
||||
event = self.service.events().get(
|
||||
@ -1060,15 +716,10 @@ class Dota2CalendarSync:
|
||||
else:
|
||||
description += f"\n{result_text}"
|
||||
|
||||
# Update summary - remove ALL existing scores and checkmarks first
|
||||
# Update summary
|
||||
summary = event.get('summary', '')
|
||||
# Remove all score patterns
|
||||
summary = re.sub(r'\d+-\d+\s+', '', summary)
|
||||
# Remove checkmark if present
|
||||
summary = re.sub(r'^(\d+[-:]\d+\s+)+', '', summary)
|
||||
summary = re.sub(r'^✓\s+', '', summary)
|
||||
# Clean up extra spaces
|
||||
summary = ' '.join(summary.split())
|
||||
# Add checkmark and final score
|
||||
summary = f"✓ {match.score} {summary}"
|
||||
|
||||
event['description'] = description
|
||||
@ -1088,15 +739,7 @@ class Dota2CalendarSync:
|
||||
return False
|
||||
|
||||
def check_time_difference(self, event_datetime: Any, new_datetime: datetime) -> bool:
|
||||
"""Check if there's a significant time difference (>= 5 minutes)
|
||||
|
||||
Args:
|
||||
event_datetime: Current event datetime (string or datetime object)
|
||||
new_datetime: New datetime to compare
|
||||
|
||||
Returns:
|
||||
True if difference is 5 minutes or more
|
||||
"""
|
||||
"""Check if there's a significant time difference (>= 5 minutes)"""
|
||||
# Parse event datetime
|
||||
if isinstance(event_datetime, str):
|
||||
if event_datetime.endswith('Z'):
|
||||
@ -1119,14 +762,7 @@ class Dota2CalendarSync:
|
||||
|
||||
@retry_on_exception(max_retries=2, delay=1.0)
|
||||
def delete_calendar_event(self, event_id: str) -> bool:
|
||||
"""Delete a calendar event
|
||||
|
||||
Args:
|
||||
event_id: Google Calendar event ID to delete
|
||||
|
||||
Returns:
|
||||
True if deletion successful, False otherwise
|
||||
"""
|
||||
"""Delete a calendar event"""
|
||||
try:
|
||||
self.service.events().delete(
|
||||
calendarId=self.calendar_id,
|
||||
@ -1139,24 +775,7 @@ class Dota2CalendarSync:
|
||||
|
||||
def process_upcoming_matches(self, upcoming_matches: List[Match], existing_events: Dict[str, Any],
|
||||
dry_run: bool, update_results: bool, update_times: bool) -> Dict[str, int]:
|
||||
"""Process upcoming matches and update calendar
|
||||
|
||||
Handles:
|
||||
- Adding new matches
|
||||
- Updating TBD placeholders with team names
|
||||
- Updating in-progress scores
|
||||
- Updating match times
|
||||
|
||||
Args:
|
||||
upcoming_matches: List of upcoming Match objects
|
||||
existing_events: Dictionary of existing calendar events
|
||||
dry_run: If True, only simulate changes
|
||||
update_results: Whether to update match scores
|
||||
update_times: Whether to update match times
|
||||
|
||||
Returns:
|
||||
Dictionary with counters for added, updated, skipped matches
|
||||
"""
|
||||
"""Process upcoming matches and update calendar"""
|
||||
counters = {
|
||||
'added': 0,
|
||||
'updated': 0,
|
||||
@ -1289,18 +908,7 @@ class Dota2CalendarSync:
|
||||
|
||||
def process_completed_matches(self, completed_matches: List[Match], existing_events: Dict[str, Any],
|
||||
dry_run: bool) -> Dict[str, int]:
|
||||
"""Process completed matches and update results
|
||||
|
||||
Updates calendar events with final scores and winners.
|
||||
|
||||
Args:
|
||||
completed_matches: List of completed Match objects
|
||||
existing_events: Dictionary of existing calendar events
|
||||
dry_run: If True, only simulate changes
|
||||
|
||||
Returns:
|
||||
Dictionary with counters for updated matches and errors
|
||||
"""
|
||||
"""Process completed matches and update results"""
|
||||
counters = {
|
||||
'updated': 0,
|
||||
'errors': 0
|
||||
@ -1339,21 +947,7 @@ class Dota2CalendarSync:
|
||||
|
||||
def clean_duplicate_and_expired_events(self, existing_events: Dict[str, Any],
|
||||
updated_tbd_events: set, dry_run: bool) -> int:
|
||||
"""Clean up duplicate and expired TBD events
|
||||
|
||||
Removes:
|
||||
- TBD events that have ended
|
||||
- Duplicate TBD vs TBD events at the same time
|
||||
- TBD events superseded by confirmed matches
|
||||
|
||||
Args:
|
||||
existing_events: Dictionary of existing calendar events
|
||||
updated_tbd_events: Set of event IDs that were updated (to skip)
|
||||
dry_run: If True, only simulate deletions
|
||||
|
||||
Returns:
|
||||
Number of events deleted
|
||||
"""
|
||||
"""Clean up duplicate and expired TBD events"""
|
||||
deleted_count = 0
|
||||
now = datetime.now(pytz.UTC)
|
||||
|
||||
@ -1424,16 +1018,14 @@ class Dota2CalendarSync:
|
||||
for tbd_event in events['tbd']:
|
||||
tbd_summary = tbd_event.get('summary', '')
|
||||
|
||||
# Delete any TBD event that has a confirmed match at the same time
|
||||
# This includes "TBD vs TBD", "TBD vs Team", and "Team vs TBD"
|
||||
if events['confirmed']:
|
||||
if 'TBD vs TBD' in tbd_summary and events['confirmed']:
|
||||
if not dry_run:
|
||||
if self.delete_calendar_event(tbd_event['id']):
|
||||
logger.info(f"🗑️ Deleted TBD event superseded by confirmed match: {tbd_summary}")
|
||||
logger.info(f"🗑️ Deleted TBD vs TBD event at same time as confirmed match")
|
||||
deleted_count += 1
|
||||
time.sleep(0.2)
|
||||
else:
|
||||
logger.info(f"◯ Would delete TBD event superseded by confirmed match: {tbd_summary}")
|
||||
logger.info(f"◯ Would delete TBD vs TBD event: {tbd_summary}")
|
||||
deleted_count += 1
|
||||
|
||||
# Delete duplicate TBD vs TBD events
|
||||
@ -1453,21 +1045,7 @@ class Dota2CalendarSync:
|
||||
return deleted_count
|
||||
|
||||
def sync_matches_to_calendar(self, dry_run=False, update_results=True, update_times=True, delete_old_tbd=True):
|
||||
"""Main sync function with improved structure and error handling
|
||||
|
||||
Coordinates the entire sync process:
|
||||
1. Fetches matches from Liquipedia
|
||||
2. Gets existing events from Google Calendar
|
||||
3. Processes upcoming and completed matches
|
||||
4. Cleans up duplicates and expired TBD events
|
||||
5. Displays summary
|
||||
|
||||
Args:
|
||||
dry_run: If True, only simulate changes
|
||||
update_results: Whether to update match scores/results
|
||||
update_times: Whether to update changed match times
|
||||
delete_old_tbd: Whether to delete expired TBD events
|
||||
"""
|
||||
"""Main sync function with improved structure and error handling"""
|
||||
logger.info("\n" + "="*50)
|
||||
logger.info("Starting Dota 2 Calendar Sync v4.0")
|
||||
logger.info("="*50 + "\n")
|
||||
@ -1536,10 +1114,6 @@ class Dota2CalendarSync:
|
||||
raise
|
||||
|
||||
def main():
|
||||
"""Main entry point for the script
|
||||
|
||||
Parses command line arguments and runs the sync process.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Sync Dota 2 Tier 1 matches from Liquipedia to Google Calendar v4.0'
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user