Compare commits

...

4 Commits

Author SHA1 Message Date
Ching L
d01c6aab85 fix: 修复标题多重比分累积问题 v4.3
All checks were successful
continuous-integration/drone/push Build is passing
- 修复 update_event_with_score() 和 update_event_with_result() 的比分清理逻辑
- 解决了标题中比分不断累积的问题(如 "✓ 2-0 1-0")
- 改进正则表达式,现在能清理所有位置的比分
- 手动修复了9个历史事件的标题

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 10:22:05 +08:00
Ching L
dc2a4c0279 feat: 添加智能合并重复比赛功能 v4.2
- 新增 _merge_duplicate_matches() 方法
- 自动检测同时间具有共同队伍的比赛并合并
- 保留更长的队伍名称作为正式名称
- 解决队伍名称变体问题(如 OG vs OG Esports)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 09:59:14 +08:00
Ching L
2b813c1ece 修复TBD事件重复问题 v4.1
- 修复TBD事件匹配逻辑,支持部分TBD事件更新
- 增强TBD事件清理,删除所有同时间的TBD事件
- 防止创建重复事件,确保TBD被更新而非新建

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 09:51:12 +08:00
Ching L
a790fc8489 docs: 为 v4.0 版本添加详细的方法注释
- 为所有公共方法添加完整的 docstring
- 添加参数说明 (Args) 和返回值说明 (Returns)
- 为复杂逻辑添加实现细节说明
- 改进 Match dataclass 的文档
- 添加装饰器和辅助函数的使用示例

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-12 18:11:52 +08:00
3 changed files with 528 additions and 45 deletions

View File

@ -1,5 +1,43 @@
# 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` 替代字典存储比赛数据,提高类型安全性
@ -180,10 +218,12 @@
| v3.8 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| v3.9 | ✓ | ✓ | ✓ | ✓ | ✓+ | ✓ |
| v4.0 | ✓ | ✓ | ✓ | ✓ | ✓+ | ✓ |
| v4.1 | ✓ | ✓ | ✓ | ✓ | ✓++ | ✓ |
| v4.2 | ✓ | ✓ | ✓ | ✓ | ✓++ | ✓ |
## 使用建议
推荐使用最新的 v4.0 版本,它包含所有功能并提供更好的错误处理:
推荐使用最新的 v4.2 版本,它包含智能合并重复比赛功能并提供更好的错误处理:
```bash
python sync_dota2_matches.py --calendar-id "YOUR_CALENDAR_ID"
```

View File

@ -1,8 +1,25 @@
# Dota 2 Calendar Sync v4.0
# Dota 2 Calendar Sync v4.3
自动从 Liquipedia 获取 Dota 2 Tier 1 比赛信息并同步到 Google Calendar支持自动更新比赛结果、时间变更、智能管理TBD占位事件、自动清理过期和重复比赛。
**v4.0 新特性**
## 更新日志
### 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)
- 🏗️ 代码结构重构:使用 dataclass 替代字典,提高类型安全性
- 🔄 增强错误处理:添加自动重试机制(指数退避)
- 📝 专业日志系统:使用 Python logging 模块,支持多级别日志

View File

@ -44,7 +44,20 @@ class MatchFormat(Enum):
@dataclass
class Match:
"""Data class for match information"""
"""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
"""
id: str
team1: str
team2: str
@ -57,12 +70,20 @@ class Match:
winner: Optional[str] = None
def __post_init__(self):
"""Ensure datetime is timezone aware"""
"""Post-initialization processing
Ensures datetime is always timezone aware (UTC)
"""
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 to dictionary for compatibility"""
"""Convert Match object to dictionary
Returns:
Dict containing all match attributes
Used for backward compatibility with existing code
"""
return {
'id': self.id,
'team1': self.team1,
@ -77,7 +98,21 @@ 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"""
"""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
"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
@ -101,13 +136,32 @@ 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"""
"""Authenticate with Google Calendar using service account credentials
Returns:
Google Calendar service object
Raises:
SystemExit: If authentication fails
"""
try:
credentials = service_account.Credentials.from_service_account_file(
self.credentials_file,
@ -122,7 +176,14 @@ 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"""
"""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
"""
url = 'https://liquipedia.net/dota2/Liquipedia:Matches'
headers = {
'User-Agent': 'Dota2CalendarSync/3.0 (https://github.com/youruser/dota2-calendar)'
@ -168,6 +229,10 @@ 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:
@ -175,7 +240,18 @@ class Dota2CalendarSync:
return [], []
def _parse_match(self, parent, timestamp_elem) -> Optional[Match]:
"""Parse match data from an element using HTML structure"""
"""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
"""
try:
# Get timestamp
timestamp = timestamp_elem.get('data-timestamp')
@ -267,7 +343,17 @@ class Dota2CalendarSync:
return None
def _extract_score(self, parent) -> Optional[str]:
"""Extract score from match element"""
"""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
"""
# Look for score in structured elements
score_holder = parent.find('div', class_='match-info-header-scoreholder')
if score_holder:
@ -295,7 +381,14 @@ class Dota2CalendarSync:
return None
def _extract_format(self, parent) -> Optional[str]:
"""Extract match format"""
"""Extract match format (Bo1, Bo3, Bo5)
Args:
parent: Parent element containing match information
Returns:
Format string (e.g., 'Bo3') or None if not found
"""
format_elem = parent.find('span', class_='match-info-header-scoreholder-lower')
if format_elem:
format_text = format_elem.get_text().strip()
@ -312,7 +405,16 @@ class Dota2CalendarSync:
return None
def _extract_tournament(self, parent) -> Optional[str]:
"""Extract tournament name"""
"""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
"""
tournament_elem = parent.find('div', class_='match-info-tournament')
if tournament_elem:
tournament_text = tournament_elem.get_text().strip()
@ -341,7 +443,16 @@ 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"""
"""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)
"""
if not format_str:
return score1 >= 2 or score2 >= 2
@ -355,15 +466,135 @@ class Dota2CalendarSync:
return True
def _clean_team_name(self, name: str) -> str:
"""Clean and normalize team name"""
"""Clean and normalize team name
Removes extra whitespace, parenthetical notes, dates, etc.
Args:
name: Raw team name from HTML
Returns:
Cleaned 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"""
"""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
"""
id_parts = []
# For TBD vs TBD matches, include datetime to make them unique
@ -383,7 +614,14 @@ 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"""
"""Remove duplicate matches based on ID
Args:
matches: List of Match objects
Returns:
List with duplicates removed (keeps last occurrence)
"""
unique_matches = {}
for match in matches:
unique_matches[match.id] = match
@ -391,7 +629,21 @@ 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"""
"""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
"""
try:
now = datetime.utcnow()
time_min = (now - timedelta(days=days_back)).isoformat() + 'Z'
@ -467,7 +719,21 @@ 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"""
"""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
"""
# Try by ID first
if match.id in existing_events:
return existing_events[match.id]
@ -490,21 +756,55 @@ class Dota2CalendarSync:
match.tournament and match.tournament in event_key):
return event
# Special handling for TBD matches
# Special handling for TBD matches - check if this match should update a TBD placeholder
if not (match.team1 == 'TBD' and match.team2 == 'TBD'):
for event_key, event in by_match.items():
if 'TBD_TBD' in event_key and match.tournament and match.tournament in event_key:
# 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:
# 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: {match.team1} vs {match.team2}")
logger.info(f"Found TBD match to update: {existing_team1} vs {existing_team2} -> {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"""
"""Create a Google Calendar event for a match
Args:
match: Match object with event details
Returns:
Dictionary with Google Calendar event format
"""
# Build summary
if match.tournament:
summary = f"{match.team1} vs {match.team2} [{match.tournament}]"
@ -562,7 +862,17 @@ 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"""
"""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
"""
try:
# Get the existing event
event = self.service.events().get(
@ -603,7 +913,17 @@ 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"""
"""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
"""
try:
# Get the existing event
event = self.service.events().get(
@ -648,7 +968,17 @@ 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"""
"""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
"""
try:
# Get the existing event
event = self.service.events().get(
@ -668,9 +998,13 @@ class Dota2CalendarSync:
else:
description += f"\n{score_text}"
# Update summary
# Update summary - remove ALL existing scores first
summary = event.get('summary', '')
summary = re.sub(r'^(\d+[-:]\d+\s+)+', '', 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 = f"{match.score} {summary}"
event['description'] = description
@ -691,7 +1025,17 @@ 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"""
"""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
"""
try:
# Get the existing event
event = self.service.events().get(
@ -716,10 +1060,15 @@ class Dota2CalendarSync:
else:
description += f"\n{result_text}"
# Update summary
# Update summary - remove ALL existing scores and checkmarks first
summary = event.get('summary', '')
summary = re.sub(r'^(\d+[-:]\d+\s+)+', '', summary)
# Remove all score patterns
summary = re.sub(r'\d+-\d+\s+', '', summary)
# Remove checkmark if present
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
@ -739,7 +1088,15 @@ 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)"""
"""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
"""
# Parse event datetime
if isinstance(event_datetime, str):
if event_datetime.endswith('Z'):
@ -762,7 +1119,14 @@ class Dota2CalendarSync:
@retry_on_exception(max_retries=2, delay=1.0)
def delete_calendar_event(self, event_id: str) -> bool:
"""Delete a calendar event"""
"""Delete a calendar event
Args:
event_id: Google Calendar event ID to delete
Returns:
True if deletion successful, False otherwise
"""
try:
self.service.events().delete(
calendarId=self.calendar_id,
@ -775,7 +1139,24 @@ 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"""
"""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
"""
counters = {
'added': 0,
'updated': 0,
@ -908,7 +1289,18 @@ 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"""
"""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
"""
counters = {
'updated': 0,
'errors': 0
@ -947,7 +1339,21 @@ 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"""
"""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
"""
deleted_count = 0
now = datetime.now(pytz.UTC)
@ -1018,14 +1424,16 @@ class Dota2CalendarSync:
for tbd_event in events['tbd']:
tbd_summary = tbd_event.get('summary', '')
if 'TBD vs TBD' in tbd_summary and events['confirmed']:
# 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 not dry_run:
if self.delete_calendar_event(tbd_event['id']):
logger.info(f"🗑️ Deleted TBD vs TBD event at same time as confirmed match")
logger.info(f"🗑️ Deleted TBD event superseded by confirmed match: {tbd_summary}")
deleted_count += 1
time.sleep(0.2)
else:
logger.info(f"◯ Would delete TBD vs TBD event: {tbd_summary}")
logger.info(f"◯ Would delete TBD event superseded by confirmed match: {tbd_summary}")
deleted_count += 1
# Delete duplicate TBD vs TBD events
@ -1045,7 +1453,21 @@ 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"""
"""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
"""
logger.info("\n" + "="*50)
logger.info("Starting Dota 2 Calendar Sync v4.0")
logger.info("="*50 + "\n")
@ -1114,6 +1536,10 @@ 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'
)