diff --git a/.gitignore b/.gitignore index 0dbf2f2..0c9bb99 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,12 @@ -# ---> Python -# Byte-compiled / optimized / DLL files +# Python __pycache__/ *.py[cod] *$py.class - -# C extensions *.so - -# Distribution / packaging .Python +env/ +venv/ +ENV/ build/ develop-eggs/ dist/ @@ -21,150 +19,47 @@ parts/ sdist/ var/ wheels/ -share/python-wheels/ *.egg-info/ .installed.cfg *.egg -MANIFEST -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env +# Virtual Environments .venv -env/ venv/ ENV/ -env.bak/ -venv.bak/ +env/ -# Spyder project settings -.spyderproject -.spyproject +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store -# Rope project settings -.ropeproject +# Credentials and sensitive data +credentials.json +token.json +client_secret*.json -# mkdocs documentation -/site +# Test and temporary files +*.log +*.tmp +test_*.html +liquipedia_page.html -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json +# Cache +.cache/ +*.cache -# Pyre type checker -.pyre/ +# Environment variables +.env +.env.local -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +# Backup files +*.bak +*.backup +*_backup.py +# Calendar ID specific files (if needed to keep private) +# calendar_config.json \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cb5cc24 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,48 @@ +# Changelog + +## v3.1 - 格式优化更新 +- **新的标题格式**: + - 之前: `Dota 2 - The International 2025: Team1 vs Team2` + - 现在: `Team1 vs Team2 [The International 2025]` +- **简化的完成标记**: + - 之前: `[COMPLETED] Dota 2 - Tournament: Team1 vs Team2` + - 现在: `✓ Team1 vs Team2 [Tournament] (2-0)` +- 移除了 "Dota 2" 字样,让日历更简洁 +- 完成的比赛直接在标题显示比分 + +## v3.0 - 时间变更检测 +- 新增比赛时间变更自动检测 +- 当赛程调整时自动更新日历 +- 支持 `--no-time-updates` 参数跳过时间更新 + +## v2.0 - 比赛结果更新 +- 自动获取已完成比赛的结果 +- 更新日历事件显示比分和获胜队伍 +- 支持 `--no-results` 参数跳过结果更新 + +## v1.0 - 基础同步 +- 从 Liquipedia 获取 Tier 1 比赛 +- 同步到 Google Calendar +- 避免重复添加 +- 支持 dry-run 模式 + +## 功能对比 + +| 版本 | 同步比赛 | 更新结果 | 时间变更 | 新格式 | +|------|---------|---------|---------|--------| +| v1.0 | ✓ | ✗ | ✗ | ✗ | +| v2.0 | ✓ | ✓ | ✗ | ✗ | +| v3.0 | ✓ | ✓ | ✓ | ✗ | +| v3.1 | ✓ | ✓ | ✓ | ✓ | + +## 使用建议 + +推荐使用最新的 v3 版本,它包含所有功能: +```bash +./run_sync.sh +``` + +或手动运行: +```bash +python sync_dota2_matches_v3.py --calendar-id "YOUR_CALENDAR_ID" +``` \ No newline at end of file diff --git a/README.md b/README.md index 92114e2..bf133fa 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,125 @@ -# dota2-match-calendar +# Dota 2 Calendar Sync v3 +自动从 Liquipedia 获取 Dota 2 Tier 1 比赛信息并同步到 Google Calendar,支持自动更新比赛结果和时间变更。 + +## 功能 + +- 自动获取 Liquipedia 上的 Tier 1 级别 Dota 2 比赛 +- 包括 The International、Major、Premier 级别赛事 +- 自动创建 Google Calendar 事件 +- **自动更新已完成比赛的结果和比分** +- **检测并更新比赛时间变更**(赛程调整时自动同步) +- 避免重复添加已存在的比赛 +- 支持 dry-run 模式进行测试 + +## 前置要求 + +### 1. Google Calendar 设置 + +你需要将你的 Google Calendar 分享给服务账号: + +1. 打开 Google Calendar +2. 进入日历设置 +3. 在"与特定人员共享"部分,添加: + - Email: `calendar-bot@tunpok.iam.gserviceaccount.com` + - 权限: "进行更改"(Make changes to events) + +### 2. Python 环境 + +使用 pyenv 和虚拟环境: + +```bash +pyenv activate test-venv +pip install -r requirements.txt +``` + +## 使用方法 + +### 基本用法 + +```bash +# 激活虚拟环境 +source $(pyenv prefix test-venv)/bin/activate + +# 运行同步(使用默认的 primary 日历) +python sync_dota2_matches.py + +# 指定特定的 Google Calendar +python sync_dota2_matches.py --calendar-id "091325d4ea74ad78387402db1a428390c4779dff573322863b6fca00194da024@group.calendar.google.com" +``` + +### Dry Run 模式 + +在实际添加事件之前,先测试看看会添加哪些比赛: + +```bash +python sync_dota2_matches.py --dry-run +``` + +### 命令行参数 + +- `--calendar-id`: Google Calendar ID 或邮箱地址(默认: primary) +- `--dry-run`: 只显示将要添加的比赛,不实际创建事件 +- `--no-results`: 跳过更新已完成比赛的结果 +- `--no-time-updates`: 跳过更新比赛时间变更 +- `--credentials`: 服务账号凭据文件路径(默认: credentials.json) + +## 文件说明 + +- `sync_dota2_matches.py`: 主同步脚本(v3版本) +- `credentials.json`: Google 服务账号凭据(需要自行添加) +- `requirements.txt`: Python 依赖包 +- `run_sync.sh`: 便捷运行脚本 +- `TIMEZONE_INFO.md`: 时区转换说明 +- `CHANGELOG.md`: 版本更新历史 +- `legacy/`: 旧版本脚本存档 + +## 功能特点 + +1. **智能匹配识别**: + - 自动识别 Tier 1、Premier、Major 级别赛事 + - 支持 The International (TI) 赛事 + - 提取比赛格式(Bo1、Bo3、Bo5) + +2. **日历事件管理**: + - 自动设置比赛时长(根据 Bo 格式估算) + - 添加 30 分钟提醒 + - 使用蓝色标记 Dota 2 事件 + - 避免重复添加 + - **自动更新已完成比赛的结果** + - **在标题添加 [COMPLETED] 标记** + +3. **错误处理**: + - 网络请求超时处理 + - API 错误重试 + - 详细的错误日志 + +## 定时运行 + +可以设置 cron job 定期运行同步: + +```bash +# 编辑 crontab +crontab -e + +# 每天早上 9 点运行同步 +0 9 * * * cd /Users/ching/develop/dota2-calendar && /Users/ching/.pyenv/versions/3.10.14/envs/test-venv/bin/python sync_dota2_matches.py --calendar-id your-email@gmail.com +``` + +## 注意事项 + +1. 确保已经将日历分享给服务账号邮箱 +2. 首次运行建议使用 `--dry-run` 测试 +3. Liquipedia 页面结构可能会变化,如果解析失败需要更新解析逻辑 + +## 故障排查 + +如果没有找到比赛: +1. 检查 Liquipedia 页面是否可以访问 +2. 运行 `python fetch_liquipedia.py` 查看页面结构 +3. 确认是否有正在进行的 Tier 1 赛事 + +如果无法添加到日历: +1. 确认已经正确分享日历给服务账号 +2. 检查 credentials.json 文件是否有效 +3. 确认使用了正确的 calendar-id \ No newline at end of file diff --git a/TIMEZONE_INFO.md b/TIMEZONE_INFO.md new file mode 100644 index 0000000..4a18924 --- /dev/null +++ b/TIMEZONE_INFO.md @@ -0,0 +1,57 @@ +# 时区说明 + +## 时间转换关系 + +Liquipedia 上 TI2025 比赛使用 **CEST (中欧夏令时)** 显示,脚本会自动转换为你的本地时间。 + +### 示例:9月5日第一场比赛 + +| 时区 | 时间 | 说明 | +|------|------|------| +| CEST (UTC+2) | 10:00 | Liquipedia 显示时间 | +| UTC | 08:00 | 世界协调时间 | +| CST/上海 (UTC+8) | 16:00 | 你的本地时间 | + +### 9月5日 TI2025 比赛时间表(上海时间) + +- **Round 3 第一批**: 16:00 (下午4点) + - XG vs Falcons + - TSpirit vs Tundra + - Aurora vs YkBros + - Nem vs Wildcard + +- **Round 3 第二批**: 19:00 (晚上7点) + - PARI vs Tidebd + - NGX vs Liquid + - NAVI vs BB + - BOOM vs HEROIC + +- **Round 4**: 22:00 (晚上10点) + - TBD vs TBD (待定) + +## 验证方法 + +1. 检查 Google Calendar 设置的时区: + - 打开 Google Calendar + - 设置 → 常规 → 时区 + - 确认是 "Asia/Shanghai" 或 "(GMT+08:00) 北京时间" + +2. 日历会自动显示本地时间: + - 脚本存储的是 UTC 时间 + - Google Calendar 自动转换为你的时区 + - 无需手动调整 + +## 常见时区对照 + +| 赛事地点 | 当地时间 | 上海时间 | +|----------|----------|----------| +| 欧洲 (CEST) | 10:00 | 16:00 | +| 欧洲 (CEST) | 13:00 | 19:00 | +| 欧洲 (CEST) | 16:00 | 22:00 | +| 欧洲 (CEST) | 19:00 | 次日 01:00 | + +## 结论 + +✅ **时间是正确的** - 日历上显示的 16:00 对应 Liquipedia 上的 10:00 CEST +✅ **自动时区转换** - Google Calendar 会根据你的时区设置自动显示正确时间 +✅ **无需调整** - 脚本已正确处理时区转换 \ No newline at end of file diff --git a/legacy/sync_dota2_matches.py b/legacy/sync_dota2_matches.py new file mode 100644 index 0000000..3da55cf --- /dev/null +++ b/legacy/sync_dota2_matches.py @@ -0,0 +1,519 @@ +#!/usr/bin/env python3 +""" +Dota 2 Tournament Calendar Sync +Fetches Tier 1 Dota 2 matches from Liquipedia and syncs them to Google Calendar +""" + +import requests +from bs4 import BeautifulSoup +from google.oauth2 import service_account +from googleapiclient.discovery import build +from datetime import datetime, timedelta +import pytz +import re +import hashlib +import sys +import argparse + +class Dota2CalendarSync: + def __init__(self, credentials_file='credentials.json', calendar_id='primary'): + 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""" + try: + credentials = service_account.Credentials.from_service_account_file( + self.credentials_file, + scopes=['https://www.googleapis.com/auth/calendar'] + ) + service = build('calendar', 'v3', credentials=credentials) + print(f"✓ Successfully authenticated with Google Calendar") + return service + except Exception as e: + print(f"✗ Authentication failed: {e}") + sys.exit(1) + + def fetch_liquipedia_matches(self): + """Fetch Tier 1 matches from Liquipedia""" + url = 'https://liquipedia.net/dota2/Liquipedia:Matches' + headers = { + 'User-Agent': 'Dota2CalendarSync/1.0 (https://github.com/youruser/dota2-calendar)' + } + + print(f"Fetching matches from Liquipedia...") + + try: + response = requests.get(url, headers=headers, timeout=30) + response.raise_for_status() + + soup = BeautifulSoup(response.text, 'lxml') + matches = [] + + # Main approach: Look for all elements with timestamps + # These contain the match information + timestamps = soup.find_all('span', {'data-timestamp': True}) + + for timestamp_elem in timestamps: + # Get the parent div that contains the full match info + parent = timestamp_elem.find_parent('div') + if not parent: + continue + + text_content = parent.get_text() + + # Check if this is a Tier 1 match + # Look for TI (The International), Major, Premier, or Tier 1 tournaments + is_tier1 = any(tier in text_content for tier in [ + 'TI2025', 'The International', 'Major', 'Premier', + 'Tier 1', 'DreamLeague', 'ESL One', 'PGL Major' + ]) + + if is_tier1: + match_data = self._parse_match_from_timestamp_element(parent, timestamp_elem) + if match_data: + matches.append(match_data) + + # Remove duplicates based on match ID + unique_matches = {} + for match in matches: + if match.get('id'): + unique_matches[match['id']] = match + + matches = list(unique_matches.values()) + + print(f"✓ Found {len(matches)} Tier 1 matches") + return matches + + except requests.RequestException as e: + print(f"✗ Error fetching Liquipedia data: {e}") + return [] + + def _parse_match_from_timestamp_element(self, parent, timestamp_elem): + """Parse match data from an element containing a timestamp""" + try: + match_data = {} + + # Get timestamp + timestamp = timestamp_elem.get('data-timestamp') + if timestamp: + match_data['datetime'] = datetime.fromtimestamp(int(timestamp), tz=pytz.UTC) + else: + return None + + # Parse the text content + text = parent.get_text() + + # Extract teams and format using improved regex + # The format from Liquipedia is like: "XGvs(Bo3)FalconsTI2025" + vs_pattern = r'([A-Za-z0-9\s\.\-_]+?)vs\(?(Bo\d)\)?([A-Za-z0-9\s\.\-_]+?)(?:TI2025|Round|Playoff|Group|\+|$)' + match = re.search(vs_pattern, text) + + if not match: + # Try alternative pattern without format + vs_pattern = r'([A-Za-z0-9\s\.\-_]+?)vs([A-Za-z0-9\s\.\-_]+?)(?:TI2025|Round|Playoff|Group|\+|$)' + match = re.search(vs_pattern, text) + + if match: + team1 = match.group(1).strip() + if len(match.groups()) > 2: + format_str = match.group(2) + team2 = match.group(3).strip() + else: + format_str = None + team2 = match.group(2).strip() + + # Clean up team names - remove any date/time remnants + team1 = re.sub(r'^.*CEST?', '', team1).strip() + + if team1 and team2: + match_data['team1'] = self._clean_team_name(team1) + match_data['team2'] = self._clean_team_name(team2) + + if format_str and format_str.startswith('Bo'): + match_data['format'] = format_str + + # Extract tournament + # Look for TI2025 or other tournament indicators + if 'TI2025' in text: + match_data['tournament'] = 'The International 2025' + # Also extract round info if present + round_match = re.search(r'Round\s+\d+', text) + if round_match: + match_data['tournament'] += f" - {round_match.group(0)}" + elif 'DreamLeague' in text: + match_data['tournament'] = 'DreamLeague' + elif 'ESL' in text: + match_data['tournament'] = 'ESL' + elif 'Major' in text: + # Try to extract full major name + major_match = re.search(r'[\w\s]+Major', text) + if major_match: + match_data['tournament'] = major_match.group(0).strip() + + # Only return if we have valid teams + if 'team1' in match_data and 'team2' in match_data: + match_data['id'] = self._generate_match_id(match_data) + return match_data + + except Exception as e: + pass + + return None + + def _extract_match_from_infobox(self, box): + """Extract match data from an infobox element""" + try: + match_data = {} + + # Extract teams + team_spans = box.find_all('span', {'class': re.compile(r'team-template|team-name')}) + if len(team_spans) >= 2: + match_data['team1'] = self._clean_team_name(team_spans[0].get_text()) + match_data['team2'] = self._clean_team_name(team_spans[1].get_text()) + + # Extract tournament + tournament_link = box.find('a', href=re.compile(r'/dota2/[^#]+')) + if tournament_link: + match_data['tournament'] = tournament_link.get_text().strip() + + # Extract datetime + timer = box.find('span', {'class': 'timer-object', 'data-timestamp': True}) + if timer: + timestamp = timer.get('data-timestamp') + match_data['datetime'] = datetime.fromtimestamp(int(timestamp), tz=pytz.UTC) + + # Extract format + format_text = box.find(string=re.compile(r'Bo\d')) + if format_text: + match_data['format'] = format_text.strip() + + if 'team1' in match_data and 'team2' in match_data: + match_data['id'] = self._generate_match_id(match_data) + return match_data + + except Exception as e: + pass + + return None + + def _extract_match_from_row(self, row): + """Extract match data from a table row""" + try: + cells = row.find_all('td') + if len(cells) < 2: + return None + + match_data = {} + + # Try to extract date/time from first cell + if cells[0]: + timer = cells[0].find('span', {'class': 'timer-object', 'data-timestamp': True}) + if timer: + timestamp = timer.get('data-timestamp') + match_data['datetime'] = datetime.fromtimestamp(int(timestamp), tz=pytz.UTC) + + # Extract teams (usually in adjacent cells) + team_cells = [] + for cell in cells: + team_elem = cell.find('span', {'class': re.compile(r'team')}) + if team_elem: + team_cells.append(team_elem) + + if len(team_cells) >= 2: + match_data['team1'] = self._clean_team_name(team_cells[0].get_text()) + match_data['team2'] = self._clean_team_name(team_cells[1].get_text()) + + # Look for tournament info + for cell in cells: + link = cell.find('a', href=re.compile(r'/dota2/[^#]+')) + if link and 'team' not in link.get('href', ''): + match_data['tournament'] = link.get_text().strip() + break + + if 'team1' in match_data and 'team2' in match_data: + match_data['id'] = self._generate_match_id(match_data) + return match_data + + except Exception: + pass + + return None + + def _extract_match_with_timer(self, parent, timer): + """Extract match data when we have a timer element""" + try: + match_data = {} + + # Get datetime from timer + timestamp = timer.get('data-timestamp') + match_data['datetime'] = datetime.fromtimestamp(int(timestamp), tz=pytz.UTC) + + # Extract teams + team_elems = parent.find_all('span', {'class': re.compile(r'team')}) + if len(team_elems) >= 2: + match_data['team1'] = self._clean_team_name(team_elems[0].get_text()) + match_data['team2'] = self._clean_team_name(team_elems[1].get_text()) + + # Extract tournament + tournament_link = parent.find('a', href=re.compile(r'/dota2/[^#]+')) + if tournament_link: + match_data['tournament'] = tournament_link.get_text().strip() + + if 'team1' in match_data and 'team2' in match_data: + match_data['id'] = self._generate_match_id(match_data) + return match_data + + except Exception: + pass + + return None + + def _clean_team_name(self, name): + """Clean and normalize team name""" + # Remove extra whitespace and common suffixes + name = re.sub(r'\s+', ' ', name).strip() + name = re.sub(r'\s*\(.*?\)\s*$', '', name) # Remove parenthetical info + return name + + def _generate_match_id(self, match_data): + """Generate a unique ID for a match""" + # Use teams and datetime if available, otherwise use what we have + id_parts = [] + + if 'team1' in match_data: + id_parts.append(match_data['team1']) + if 'team2' in match_data: + id_parts.append(match_data['team2']) + if 'datetime' in match_data: + id_parts.append(str(match_data['datetime'].date())) + if 'tournament' in match_data: + id_parts.append(match_data['tournament']) + + unique_string = '_'.join(id_parts) + return hashlib.md5(unique_string.encode()).hexdigest()[:16] + + def get_existing_events(self, days_ahead=30): + """Get existing Dota 2 events from Google Calendar""" + try: + now = datetime.utcnow() + time_min = now.isoformat() + 'Z' + time_max = (now + timedelta(days=days_ahead)).isoformat() + 'Z' + + print(f"Checking existing events in calendar...") + + events_result = self.service.events().list( + calendarId=self.calendar_id, + timeMin=time_min, + timeMax=time_max, + maxResults=200, + singleEvents=True, + orderBy='startTime' + ).execute() + + events = events_result.get('items', []) + + # Filter for Dota 2 events and extract IDs + dota_events = {} + for event in events: + if 'Dota 2' in event.get('summary', ''): + description = event.get('description', '') + # Extract ID from description + id_match = re.search(r'ID:\s*([a-f0-9]+)', description) + if id_match: + dota_events[id_match.group(1)] = event + + print(f"✓ Found {len(dota_events)} existing Dota 2 events") + return dota_events + + except Exception as e: + print(f"✗ Error fetching calendar events: {e}") + return {} + + def create_calendar_event(self, match_data): + """Create a Google Calendar event for a match""" + # Build event summary + team1 = match_data.get('team1', 'TBD') + team2 = match_data.get('team2', 'TBD') + tournament = match_data.get('tournament', '') + + if tournament: + summary = f"Dota 2 - {tournament}: {team1} vs {team2}" + else: + summary = f"Dota 2: {team1} vs {team2}" + + # Build description + description_parts = [] + if tournament: + description_parts.append(f"Tournament: {tournament}") + description_parts.append(f"Match: {team1} vs {team2}") + if 'format' in match_data: + description_parts.append(f"Format: {match_data['format']}") + description_parts.append(f"ID: {match_data['id']}") + description_parts.append("\nSource: Liquipedia") + + description = '\n'.join(description_parts) + + # Set start and end times + start_time = match_data.get('datetime', datetime.now(pytz.UTC)) + # Estimate match duration based on format + duration = 2 # Default 2 hours + if 'format' in match_data: + if 'Bo5' in match_data['format']: + duration = 4 + elif 'Bo3' in match_data['format']: + duration = 3 + elif 'Bo1' in match_data['format']: + duration = 1 + + end_time = start_time + timedelta(hours=duration) + + event = { + 'summary': summary, + 'description': description, + 'start': { + 'dateTime': start_time.isoformat(), + 'timeZone': 'UTC', + }, + 'end': { + 'dateTime': end_time.isoformat(), + 'timeZone': 'UTC', + }, + 'reminders': { + 'useDefault': False, + 'overrides': [ + {'method': 'popup', 'minutes': 30}, + ], + }, + 'colorId': '9', # Blue color for Dota 2 events + } + + return event + + def sync_matches_to_calendar(self, dry_run=False): + """Main sync function""" + print("\n" + "="*50) + print("Starting Dota 2 Calendar Sync") + print("="*50 + "\n") + + # Fetch matches from Liquipedia + matches = self.fetch_liquipedia_matches() + + if not matches: + print("No matches found to sync") + return + + # Filter for future matches only + now = datetime.now(pytz.UTC) + future_matches = [m for m in matches if m.get('datetime', now) >= now] + + print(f"Filtered to {len(future_matches)} future matches") + + if not future_matches: + print("No future matches to sync") + return + + # Get existing events + existing_events = self.get_existing_events() + + # Process each match + added_count = 0 + skipped_count = 0 + error_count = 0 + + print("\nProcessing matches...") + print("-" * 30) + + for match in future_matches: + match_id = match.get('id') + team1 = match.get('team1', 'TBD') + team2 = match.get('team2', 'TBD') + match_time = match.get('datetime', now) + + if not match_id: + continue + + if match_id in existing_events: + print(f"⊘ Skipping (exists): {team1} vs {team2}") + skipped_count += 1 + else: + if dry_run: + print(f"◯ Would add: {team1} vs {team2} at {match_time.strftime('%Y-%m-%d %H:%M UTC')}") + added_count += 1 + else: + try: + event = self.create_calendar_event(match) + self.service.events().insert( + calendarId=self.calendar_id, + body=event + ).execute() + print(f"✓ Added: {team1} vs {team2} at {match_time.strftime('%Y-%m-%d %H:%M UTC')}") + added_count += 1 + except Exception as e: + print(f"✗ Error adding {team1} vs {team2}: {e}") + error_count += 1 + + # Summary + print("\n" + "="*50) + print("Sync Summary") + print("="*50) + print(f"✓ Added: {added_count} matches") + print(f"⊘ Skipped: {skipped_count} matches (already exist)") + if error_count > 0: + print(f"✗ Errors: {error_count} matches") + + if dry_run: + print("\n⚠ DRY RUN - No actual changes were made") + + print("\n✓ Sync complete!") + +def main(): + parser = argparse.ArgumentParser( + description='Sync Dota 2 Tier 1 matches from Liquipedia to Google Calendar' + ) + parser.add_argument( + '--calendar-id', + default='primary', + help='Google Calendar ID (default: primary). Use email address for specific calendar.' + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='Perform a dry run without actually creating events' + ) + parser.add_argument( + '--credentials', + default='credentials.json', + help='Path to Google service account credentials JSON file' + ) + + args = parser.parse_args() + + # Important notice + print("\n" + "!"*60) + print("IMPORTANT: Before using this script:") + print("1. Share your Google Calendar with the service account") + print(" Service Account Email: calendar-bot@tunpok.iam.gserviceaccount.com") + print("2. Grant 'Make changes to events' permission") + print("3. Use your calendar email as --calendar-id parameter") + print("!"*60 + "\n") + + # Initialize and run sync + try: + sync = Dota2CalendarSync( + credentials_file=args.credentials, + calendar_id=args.calendar_id + ) + + sync.sync_matches_to_calendar(dry_run=args.dry_run) + + except KeyboardInterrupt: + print("\n\nSync cancelled by user") + sys.exit(0) + except Exception as e: + print(f"\n✗ Fatal error: {e}") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/legacy/sync_dota2_matches_v2.py b/legacy/sync_dota2_matches_v2.py new file mode 100644 index 0000000..37f0565 --- /dev/null +++ b/legacy/sync_dota2_matches_v2.py @@ -0,0 +1,564 @@ +#!/usr/bin/env python3 +""" +Dota 2 Tournament Calendar Sync v2 +Fetches Tier 1 Dota 2 matches from Liquipedia and syncs them to Google Calendar +Now includes completed match results updating +""" + +import requests +from bs4 import BeautifulSoup +from google.oauth2 import service_account +from googleapiclient.discovery import build +from datetime import datetime, timedelta +import pytz +import re +import hashlib +import sys +import argparse +import time + +class Dota2CalendarSync: + def __init__(self, credentials_file='credentials.json', calendar_id='primary'): + 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""" + try: + credentials = service_account.Credentials.from_service_account_file( + self.credentials_file, + scopes=['https://www.googleapis.com/auth/calendar'] + ) + service = build('calendar', 'v3', credentials=credentials) + print(f"✓ Successfully authenticated with Google Calendar") + return service + except Exception as e: + print(f"✗ Authentication failed: {e}") + sys.exit(1) + + def fetch_all_matches(self): + """Fetch both upcoming and completed matches from Liquipedia""" + url = 'https://liquipedia.net/dota2/Liquipedia:Matches' + headers = { + 'User-Agent': 'Dota2CalendarSync/2.0 (https://github.com/youruser/dota2-calendar)' + } + + print(f"Fetching matches from Liquipedia...") + + try: + response = requests.get(url, headers=headers, timeout=30) + response.raise_for_status() + + soup = BeautifulSoup(response.text, 'lxml') + upcoming_matches = [] + completed_matches = [] + + # Find all match containers + all_divs = soup.find_all('div', recursive=True) + + for div in all_divs: + text_content = div.get_text() + + # Check if this is a Tier 1 match + is_tier1 = any(tier in text_content for tier in [ + 'TI2025', 'The International', 'Major', 'Premier', + 'Tier 1', 'DreamLeague', 'ESL One', 'PGL Major' + ]) + + if not is_tier1: + continue + + # Check if it's a completed match (has score) + score_pattern = r'\d+[-:]\d+' + has_score = re.search(score_pattern, text_content) + + # Look for timestamp + timestamp_elem = div.find('span', {'data-timestamp': True}) + + if timestamp_elem: + if has_score: + # This is a completed match + match_data = self._parse_completed_match(div, timestamp_elem) + if match_data: + completed_matches.append(match_data) + else: + # This is an upcoming match + match_data = self._parse_upcoming_match(div, timestamp_elem) + if match_data: + upcoming_matches.append(match_data) + + # Remove duplicates + upcoming_matches = self._remove_duplicates(upcoming_matches) + completed_matches = self._remove_duplicates(completed_matches) + + print(f"✓ Found {len(upcoming_matches)} upcoming matches") + print(f"✓ Found {len(completed_matches)} completed matches with results") + + return upcoming_matches, completed_matches + + except requests.RequestException as e: + print(f"✗ Error fetching Liquipedia data: {e}") + return [], [] + + def _parse_completed_match(self, div, timestamp_elem): + """Parse a completed match with result""" + try: + match_data = {} + + # Get timestamp + timestamp = timestamp_elem.get('data-timestamp') + if timestamp: + match_data['datetime'] = datetime.fromtimestamp(int(timestamp), tz=pytz.UTC) + + text = div.get_text() + + # Extract teams and score + # Common patterns: "Team1 2-0 Team2", "Team1 2:1 Team2" + score_patterns = [ + r'([A-Za-z0-9\s\.\-_]+?)\s+(\d+)[-:](\d+)\s+([A-Za-z0-9\s\.\-_]+)', + r'([A-Za-z0-9\s\.\-_]+?)(\d+)[-:](\d+)([A-Za-z0-9\s\.\-_]+)', + ] + + for pattern in score_patterns: + match = re.search(pattern, text) + if match: + team1 = self._clean_team_name(match.group(1)) + score1 = match.group(2) + score2 = match.group(3) + team2 = self._clean_team_name(match.group(4)) + + if team1 and team2: + match_data['team1'] = team1 + match_data['team2'] = team2 + match_data['score'] = f"{score1}-{score2}" + + # Determine winner + if int(score1) > int(score2): + match_data['winner'] = team1 + else: + match_data['winner'] = team2 + break + + # Extract tournament + if 'TI2025' in text: + match_data['tournament'] = 'The International 2025' + round_match = re.search(r'Round\s+\d+', text) + if round_match: + match_data['tournament'] += f" - {round_match.group(0)}" + + # Generate ID if we have valid data + if 'team1' in match_data and 'team2' in match_data: + match_data['id'] = self._generate_match_id(match_data) + match_data['completed'] = True + return match_data + + except Exception as e: + pass + + return None + + def _parse_upcoming_match(self, div, timestamp_elem): + """Parse an upcoming match""" + try: + match_data = {} + + # Get timestamp + timestamp = timestamp_elem.get('data-timestamp') + if timestamp: + match_data['datetime'] = datetime.fromtimestamp(int(timestamp), tz=pytz.UTC) + else: + return None + + text = div.get_text() + + # Extract teams and format + vs_pattern = r'([A-Za-z0-9\s\.\-_]+?)vs\(?(Bo\d)\)?([A-Za-z0-9\s\.\-_]+?)(?:TI2025|Round|Playoff|Group|\+|$)' + match = re.search(vs_pattern, text) + + if not match: + vs_pattern = r'([A-Za-z0-9\s\.\-_]+?)vs([A-Za-z0-9\s\.\-_]+?)(?:TI2025|Round|Playoff|Group|\+|$)' + match = re.search(vs_pattern, text) + + if match: + team1 = match.group(1).strip() + if len(match.groups()) > 2: + format_str = match.group(2) + team2 = match.group(3).strip() + else: + format_str = None + team2 = match.group(2).strip() + + # Clean up team names + team1 = re.sub(r'^.*CEST?', '', team1).strip() + + if team1 and team2: + match_data['team1'] = self._clean_team_name(team1) + match_data['team2'] = self._clean_team_name(team2) + + if format_str and format_str.startswith('Bo'): + match_data['format'] = format_str + + # Extract tournament + if 'TI2025' in text: + match_data['tournament'] = 'The International 2025' + round_match = re.search(r'Round\s+\d+', text) + if round_match: + match_data['tournament'] += f" - {round_match.group(0)}" + + # Only return if we have valid teams + if 'team1' in match_data and 'team2' in match_data: + match_data['id'] = self._generate_match_id(match_data) + match_data['completed'] = False + return match_data + + except Exception as e: + pass + + return None + + def _clean_team_name(self, name): + """Clean and normalize team name""" + name = re.sub(r'\s+', ' ', name).strip() + name = re.sub(r'\s*\(.*?\)\s*$', '', name) + # Remove date/time patterns + 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 _generate_match_id(self, match_data): + """Generate a unique ID for a match""" + id_parts = [] + + if 'team1' in match_data: + id_parts.append(match_data['team1']) + if 'team2' in match_data: + id_parts.append(match_data['team2']) + if 'datetime' in match_data: + id_parts.append(str(match_data['datetime'].date())) + if 'tournament' in match_data: + id_parts.append(match_data['tournament']) + + unique_string = '_'.join(id_parts) + return hashlib.md5(unique_string.encode()).hexdigest()[:16] + + def _remove_duplicates(self, matches): + """Remove duplicate matches based on ID""" + unique_matches = {} + for match in matches: + if match.get('id'): + unique_matches[match['id']] = match + return list(unique_matches.values()) + + def get_existing_events(self, days_back=7, days_ahead=30): + """Get existing Dota 2 events from Google Calendar""" + try: + now = datetime.utcnow() + time_min = (now - timedelta(days=days_back)).isoformat() + 'Z' + time_max = (now + timedelta(days=days_ahead)).isoformat() + 'Z' + + print(f"Checking existing events in calendar...") + + events_result = self.service.events().list( + calendarId=self.calendar_id, + timeMin=time_min, + timeMax=time_max, + maxResults=500, + singleEvents=True, + orderBy='startTime' + ).execute() + + events = events_result.get('items', []) + + # Filter for Dota 2 events and extract IDs + dota_events = {} + for event in events: + if 'Dota 2' in event.get('summary', ''): + description = event.get('description', '') + # Extract ID from description + id_match = re.search(r'ID:\s*([a-f0-9]+)', description) + if id_match: + dota_events[id_match.group(1)] = event + + print(f"✓ Found {len(dota_events)} existing Dota 2 events") + return dota_events + + except Exception as e: + print(f"✗ Error fetching calendar events: {e}") + return {} + + def create_calendar_event(self, match_data): + """Create a Google Calendar event for a match""" + # Build event summary + team1 = match_data.get('team1', 'TBD') + team2 = match_data.get('team2', 'TBD') + tournament = match_data.get('tournament', '') + + if tournament: + summary = f"Dota 2 - {tournament}: {team1} vs {team2}" + else: + summary = f"Dota 2: {team1} vs {team2}" + + # Build description + description_parts = [] + if tournament: + description_parts.append(f"Tournament: {tournament}") + description_parts.append(f"Match: {team1} vs {team2}") + if 'format' in match_data: + description_parts.append(f"Format: {match_data['format']}") + if match_data.get('completed'): + description_parts.append(f"\n🏆 RESULT: {match_data.get('score', 'Unknown')}") + description_parts.append(f"Winner: {match_data.get('winner', 'Unknown')}") + description_parts.append(f"ID: {match_data['id']}") + description_parts.append("\nSource: Liquipedia") + + description = '\n'.join(description_parts) + + # Set start and end times + start_time = match_data.get('datetime', datetime.now(pytz.UTC)) + # Estimate match duration based on format + duration = 2 # Default 2 hours + if 'format' in match_data: + if 'Bo5' in match_data['format']: + duration = 4 + elif 'Bo3' in match_data['format']: + duration = 3 + elif 'Bo1' in match_data['format']: + duration = 1 + + end_time = start_time + timedelta(hours=duration) + + event = { + 'summary': summary, + 'description': description, + 'start': { + 'dateTime': start_time.isoformat(), + 'timeZone': 'UTC', + }, + 'end': { + 'dateTime': end_time.isoformat(), + 'timeZone': 'UTC', + }, + 'reminders': { + 'useDefault': False, + 'overrides': [ + {'method': 'popup', 'minutes': 30}, + ], + }, + 'colorId': '9', # Blue color for Dota 2 events + } + + return event + + def update_event_with_result(self, event_id, match_data): + """Update an existing calendar event with match results""" + try: + # Get the existing event + event = self.service.events().get( + calendarId=self.calendar_id, + eventId=event_id + ).execute() + + # Update the description with results + description = event.get('description', '') + + # Check if results are already in the description + if '🏆 RESULT:' in description: + # Update existing result + description = re.sub( + r'🏆 RESULT:.*?\n.*?Winner:.*?\n', + f"🏆 RESULT: {match_data.get('score', 'Unknown')}\nWinner: {match_data.get('winner', 'Unknown')}\n", + description, + flags=re.DOTALL + ) + else: + # Add new result + result_text = f"\n🏆 RESULT: {match_data.get('score', 'Unknown')}\nWinner: {match_data.get('winner', 'Unknown')}\n" + # Insert result before ID line + if 'ID:' in description: + description = description.replace('ID:', result_text + 'ID:') + else: + description += result_text + + # Update the summary to show it's completed + summary = event.get('summary', '') + if '[COMPLETED]' not in summary: + summary = f"[COMPLETED] {summary}" + + # Update the event + event['description'] = description + event['summary'] = summary + + updated_event = self.service.events().update( + calendarId=self.calendar_id, + eventId=event_id, + body=event + ).execute() + + return True + + except Exception as e: + print(f"Error updating event: {e}") + return False + + def sync_matches_to_calendar(self, dry_run=False, update_results=True): + """Main sync function with result updating""" + print("\n" + "="*50) + print("Starting Dota 2 Calendar Sync v2") + print("="*50 + "\n") + + # Fetch all matches (upcoming and completed) + upcoming_matches, completed_matches = self.fetch_all_matches() + + if not upcoming_matches and not completed_matches: + print("No matches found to sync") + return + + # Get existing events (including past week for result updates) + existing_events = self.get_existing_events(days_back=7, days_ahead=30) + + # Process upcoming matches + added_count = 0 + skipped_count = 0 + updated_count = 0 + error_count = 0 + + print("\nProcessing upcoming matches...") + print("-" * 30) + + # Filter for future matches only + now = datetime.now(pytz.UTC) + future_matches = [m for m in upcoming_matches if m.get('datetime', now) >= now] + + for match in future_matches: + match_id = match.get('id') + team1 = match.get('team1', 'TBD') + team2 = match.get('team2', 'TBD') + match_time = match.get('datetime', now) + + if not match_id: + continue + + if match_id in existing_events: + print(f"⊘ Skipping (exists): {team1} vs {team2}") + skipped_count += 1 + else: + if dry_run: + print(f"◯ Would add: {team1} vs {team2} at {match_time.strftime('%Y-%m-%d %H:%M UTC')}") + added_count += 1 + else: + try: + event = self.create_calendar_event(match) + self.service.events().insert( + calendarId=self.calendar_id, + body=event + ).execute() + print(f"✓ Added: {team1} vs {team2} at {match_time.strftime('%Y-%m-%d %H:%M UTC')}") + added_count += 1 + time.sleep(0.2) # Rate limiting + except Exception as e: + print(f"✗ Error adding {team1} vs {team2}: {e}") + error_count += 1 + + # Process completed matches to update results + if update_results and completed_matches: + print("\nProcessing completed match results...") + print("-" * 30) + + for match in completed_matches: + match_id = match.get('id') + team1 = match.get('team1', 'TBD') + team2 = match.get('team2', 'TBD') + score = match.get('score', 'Unknown') + + if not match_id: + continue + + if match_id in existing_events: + existing_event = existing_events[match_id] + + # Check if already marked as completed + if '[COMPLETED]' in existing_event.get('summary', ''): + print(f"⊘ Already updated: {team1} vs {team2} ({score})") + else: + if dry_run: + print(f"◯ Would update: {team1} vs {team2} with result {score}") + updated_count += 1 + else: + if self.update_event_with_result(existing_event['id'], match): + print(f"✓ Updated: {team1} vs {team2} - Result: {score}") + updated_count += 1 + time.sleep(0.2) # Rate limiting + else: + print(f"✗ Failed to update: {team1} vs {team2}") + error_count += 1 + + # Summary + print("\n" + "="*50) + print("Sync Summary") + print("="*50) + print(f"✓ Added: {added_count} matches") + print(f"✓ Updated with results: {updated_count} matches") + print(f"⊘ Skipped: {skipped_count} matches (already exist)") + if error_count > 0: + print(f"✗ Errors: {error_count} matches") + + if dry_run: + print("\n⚠ DRY RUN - No actual changes were made") + + print("\n✓ Sync complete!") + +def main(): + parser = argparse.ArgumentParser( + description='Sync Dota 2 Tier 1 matches from Liquipedia to Google Calendar with result updates' + ) + parser.add_argument( + '--calendar-id', + default='primary', + help='Google Calendar ID (default: primary). Use email address for specific calendar.' + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='Perform a dry run without actually creating/updating events' + ) + parser.add_argument( + '--no-results', + action='store_true', + help='Skip updating completed match results' + ) + parser.add_argument( + '--credentials', + default='credentials.json', + help='Path to Google service account credentials JSON file' + ) + + args = parser.parse_args() + + # Important notice + print("\n" + "!"*60) + print("Dota 2 Calendar Sync v2 - Now with Match Results!") + print("Service Account: calendar-bot@tunpok.iam.gserviceaccount.com") + print("!"*60 + "\n") + + # Initialize and run sync + try: + sync = Dota2CalendarSync( + credentials_file=args.credentials, + calendar_id=args.calendar_id + ) + + sync.sync_matches_to_calendar( + dry_run=args.dry_run, + update_results=not args.no_results + ) + + except KeyboardInterrupt: + print("\n\nSync cancelled by user") + sys.exit(0) + except Exception as e: + print(f"\n✗ Fatal error: {e}") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c2a354f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +beautifulsoup4==4.12.3 +requests==2.32.3 +google-auth==2.35.0 +google-auth-httplib2==0.2.0 +google-api-python-client==2.154.0 +python-dateutil==2.9.0 +pytz==2024.2 +lxml==5.3.0 \ No newline at end of file diff --git a/run_sync.sh b/run_sync.sh new file mode 100755 index 0000000..d7c7ed2 --- /dev/null +++ b/run_sync.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +# Dota 2 Calendar Sync Script v3 +# 自动同步 Liquipedia Tier 1 比赛到 Google Calendar +# 功能:同步比赛、更新结果、检测时间变更 + +CALENDAR_ID="091325d4ea74ad78387402db1a428390c4779dff573322863b6fca00194da024@group.calendar.google.com" + +echo "🎮 Dota 2 Calendar Sync v3" +echo "===========================" +echo "📊 Match results updates" +echo "⏰ Time change detection" +echo "" + +# 运行同步 +python3 sync_dota2_matches.py --calendar-id "$CALENDAR_ID" + +echo "" +echo "✅ 同步完成!" +echo "📅 请在 Google Calendar 中查看更新的比赛" +echo "🏆 已完成的比赛会显示 [COMPLETED] 标记和比分" \ No newline at end of file diff --git a/sync_dota2_matches.py b/sync_dota2_matches.py new file mode 100644 index 0000000..b92b775 --- /dev/null +++ b/sync_dota2_matches.py @@ -0,0 +1,698 @@ +#!/usr/bin/env python3 +""" +Dota 2 Tournament Calendar Sync v3 +Fetches Tier 1 Dota 2 matches from Liquipedia and syncs them to Google Calendar +Features: +- Sync upcoming matches +- Update completed match results +- Update match times if they change +""" + +import requests +from bs4 import BeautifulSoup +from google.oauth2 import service_account +from googleapiclient.discovery import build +from datetime import datetime, timedelta +import pytz +import re +import hashlib +import sys +import argparse +import time + +class Dota2CalendarSync: + def __init__(self, credentials_file='credentials.json', calendar_id='primary'): + 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""" + try: + credentials = service_account.Credentials.from_service_account_file( + self.credentials_file, + scopes=['https://www.googleapis.com/auth/calendar'] + ) + service = build('calendar', 'v3', credentials=credentials) + print(f"✓ Successfully authenticated with Google Calendar") + return service + except Exception as e: + print(f"✗ Authentication failed: {e}") + sys.exit(1) + + def fetch_all_matches(self): + """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)' + } + + print(f"Fetching matches from Liquipedia...") + + try: + response = requests.get(url, headers=headers, timeout=30) + response.raise_for_status() + + soup = BeautifulSoup(response.text, 'lxml') + matches = [] + + # Find all timestamps (these contain match info) + timestamps = soup.find_all('span', {'data-timestamp': True}) + + for timestamp_elem in timestamps: + parent = timestamp_elem.find_parent('div') + if not parent: + continue + + text_content = parent.get_text() + + # Check if this is a Tier 1 match + is_tier1 = any(tier in text_content for tier in [ + 'TI2025', 'The International', 'Major', 'Premier', + 'Tier 1', 'DreamLeague', 'ESL One', 'PGL Major' + ]) + + if is_tier1: + match_data = self._parse_match(parent, timestamp_elem) + if match_data: + matches.append(match_data) + + # Remove duplicates + matches = self._remove_duplicates(matches) + + # Separate upcoming and completed matches + upcoming = [m for m in matches if not m.get('completed', False)] + completed = [m for m in matches if m.get('completed', False)] + + print(f"✓ Found {len(upcoming)} upcoming matches") + print(f"✓ Found {len(completed)} completed matches with results") + + return upcoming, completed + + except requests.RequestException as e: + print(f"✗ Error fetching Liquipedia data: {e}") + return [], [] + + def _parse_match(self, parent, timestamp_elem): + """Parse match data from an element""" + try: + match_data = {} + + # Get timestamp + timestamp = timestamp_elem.get('data-timestamp') + if timestamp: + match_data['datetime'] = datetime.fromtimestamp(int(timestamp), tz=pytz.UTC) + else: + return None + + text = parent.get_text() + + # Check if it has a score (completed match) + score_match = re.search(r'(\d+)[-:](\d+)', text) + has_score = score_match and not ('10:00' in text or '13:00' in text or '16:00' in text) + + # Extract teams and format + vs_pattern = r'([A-Za-z0-9\s\.\-_]+?)vs\(?(Bo\d)\)?([A-Za-z0-9\s\.\-_]+?)(?:TI2025|Round|Playoff|Group|\+|$)' + match = re.search(vs_pattern, text) + + if not match: + vs_pattern = r'([A-Za-z0-9\s\.\-_]+?)vs([A-Za-z0-9\s\.\-_]+?)(?:TI2025|Round|Playoff|Group|\+|$)' + match = re.search(vs_pattern, text) + + if match: + team1 = match.group(1).strip() + if len(match.groups()) > 2: + format_str = match.group(2) + team2 = match.group(3).strip() + else: + format_str = None + team2 = match.group(2).strip() + + # Clean up team names + team1 = re.sub(r'^.*CEST?', '', team1).strip() + + if team1 and team2: + match_data['team1'] = self._clean_team_name(team1) + match_data['team2'] = self._clean_team_name(team2) + + if format_str and format_str.startswith('Bo'): + match_data['format'] = format_str + + # Extract tournament + if 'TI2025' in text: + match_data['tournament'] = 'The International 2025' + round_match = re.search(r'Round\s+\d+', text) + if round_match: + match_data['tournament'] += f" - {round_match.group(0)}" + elif 'Major' in text: + major_match = re.search(r'[\w\s]+Major', text) + if major_match: + match_data['tournament'] = major_match.group(0).strip() + + # Mark if completed + if has_score and score_match: + match_data['completed'] = True + match_data['score'] = f"{score_match.group(1)}-{score_match.group(2)}" + # Determine winner + if int(score_match.group(1)) > int(score_match.group(2)): + match_data['winner'] = match_data.get('team1', 'Unknown') + else: + match_data['winner'] = match_data.get('team2', 'Unknown') + else: + match_data['completed'] = False + + # Generate ID if we have valid data + if 'team1' in match_data and 'team2' in match_data: + match_data['id'] = self._generate_match_id(match_data) + return match_data + + except Exception as e: + pass + + return None + + def _clean_team_name(self, 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 _generate_match_id(self, match_data): + """Generate a unique ID for a match""" + # Use teams and tournament for ID (not datetime to handle reschedules) + id_parts = [] + + if 'team1' in match_data: + id_parts.append(match_data['team1']) + if 'team2' in match_data: + id_parts.append(match_data['team2']) + if 'tournament' in match_data: + id_parts.append(match_data['tournament']) + else: + # Fall back to date if no tournament + if 'datetime' in match_data: + id_parts.append(str(match_data['datetime'].date())) + + unique_string = '_'.join(id_parts) + return hashlib.md5(unique_string.encode()).hexdigest()[:16] + + def _remove_duplicates(self, matches): + """Remove duplicate matches based on ID""" + unique_matches = {} + for match in matches: + if match.get('id'): + unique_matches[match['id']] = match + return list(unique_matches.values()) + + def get_existing_events(self, days_back=7, days_ahead=30): + """Get existing Dota 2 events from Google Calendar""" + try: + now = datetime.utcnow() + time_min = (now - timedelta(days=days_back)).isoformat() + 'Z' + time_max = (now + timedelta(days=days_ahead)).isoformat() + 'Z' + + print(f"Checking existing events in calendar...") + + events_result = self.service.events().list( + calendarId=self.calendar_id, + timeMin=time_min, + timeMax=time_max, + maxResults=500, + singleEvents=True, + orderBy='startTime' + ).execute() + + events = events_result.get('items', []) + + # Filter for Dota 2 events + # Build multiple indexes for better matching + dota_events_by_id = {} + dota_events_by_match = {} + + for event in events: + summary = event.get('summary', '') + # Check for Dota events - old format has "Dota 2", new format has tournament brackets + is_dota = ('Dota 2' in summary or + 'The International' in summary or + 'TI2025' in summary or + '[' in summary and 'vs' in summary) # New format has brackets + + if is_dota: + description = event.get('description', '') + + # Extract ID from description (for old events) + id_match = re.search(r'ID:\s*([a-f0-9]+)', description) + if id_match: + dota_events_by_id[id_match.group(1)] = event + + # Also create key based on teams and tournament for matching + summary = event.get('summary', '') + # Remove completed markers + summary = summary.replace('[COMPLETED] ', '').replace('✓ ', '') + # Remove score if present + summary = re.sub(r'\s*\([0-9\-\?]+\)\s*$', '', summary) + + # Try new format first: "Team1 vs Team2 [Tournament]" + match = re.search(r'^(.*?)\s+vs\s+(.*?)\s*\[(.*?)\]$', summary) + if match: + team1 = match.group(1).strip() + team2 = match.group(2).strip() + tournament = match.group(3).strip() + else: + # Try old format: "Dota 2 - Tournament: Team1 vs Team2" + match = re.search(r'Dota 2 - (.*?):\s*(.*?)\s+vs\s+(.*?)$', summary) + if match: + tournament = match.group(1).strip() + team1 = match.group(2).strip() + team2 = match.group(3).strip() + + if match: + + # Create match key (teams + tournament) + match_key = f"{team1}_{team2}_{tournament}" + dota_events_by_match[match_key] = event + + print(f"✓ Found {len(dota_events_by_id)} existing Dota 2 events") + + # Return combined dictionary for backward compatibility + combined = {} + combined.update(dota_events_by_id) + # Store the by_match index as a special key + combined['_by_match'] = dota_events_by_match + + return combined + + except Exception as e: + print(f"✗ Error fetching calendar events: {e}") + return {} + + def create_calendar_event(self, match_data): + """Create a Google Calendar event for a match""" + team1 = match_data.get('team1', 'TBD') + team2 = match_data.get('team2', 'TBD') + tournament = match_data.get('tournament', '') + + # New format: Teams first, tournament in brackets + if tournament: + summary = f"{team1} vs {team2} [{tournament}]" + else: + summary = f"{team1} vs {team2}" + + # Build description + description_parts = [] + if tournament: + description_parts.append(f"Tournament: {tournament}") + description_parts.append(f"Match: {team1} vs {team2}") + if 'format' in match_data: + description_parts.append(f"Format: {match_data['format']}") + if match_data.get('completed'): + description_parts.append(f"\n🏆 RESULT: {match_data.get('score', 'Unknown')}") + description_parts.append(f"Winner: {match_data.get('winner', 'Unknown')}") + description_parts.append(f"ID: {match_data['id']}") + description_parts.append("\nSource: Liquipedia") + + description = '\n'.join(description_parts) + + # Set start and end times + start_time = match_data.get('datetime', datetime.now(pytz.UTC)) + # Estimate duration + duration = 2 + if 'format' in match_data: + if 'Bo5' in match_data['format']: + duration = 4 + elif 'Bo3' in match_data['format']: + duration = 3 + elif 'Bo1' in match_data['format']: + duration = 1 + + end_time = start_time + timedelta(hours=duration) + + event = { + 'summary': summary, + 'description': description, + 'start': { + 'dateTime': start_time.isoformat(), + 'timeZone': 'UTC', + }, + 'end': { + 'dateTime': end_time.isoformat(), + 'timeZone': 'UTC', + }, + 'reminders': { + 'useDefault': False, + 'overrides': [ + {'method': 'popup', 'minutes': 30}, + ], + }, + 'colorId': '9', # Blue + } + + return event + + def update_event_time(self, event_id, new_datetime): + """Update the time of an existing event""" + try: + # Get the existing event + event = self.service.events().get( + calendarId=self.calendar_id, + eventId=event_id + ).execute() + + # Calculate duration from existing event + start_dt = datetime.fromisoformat(event['start']['dateTime'].replace('Z', '+00:00')) + end_dt = datetime.fromisoformat(event['end']['dateTime'].replace('Z', '+00:00')) + duration = end_dt - start_dt + + # Update times + event['start']['dateTime'] = new_datetime.isoformat() + event['end']['dateTime'] = (new_datetime + duration).isoformat() + + # Add note about time change to description + description = event.get('description', '') + if 'Last updated:' in description: + # Update the timestamp + description = re.sub( + r'Last updated:.*', + f"Last updated: {datetime.now(pytz.UTC).strftime('%Y-%m-%d %H:%M UTC')}", + description + ) + else: + # Add timestamp + description += f"\nLast updated: {datetime.now(pytz.UTC).strftime('%Y-%m-%d %H:%M UTC')}" + event['description'] = description + + # Update the event + updated_event = self.service.events().update( + calendarId=self.calendar_id, + eventId=event_id, + body=event + ).execute() + + return True + + except Exception as e: + print(f"Error updating event time: {e}") + return False + + def update_event_with_result(self, event_id, match_data): + """Update an existing calendar event with match results""" + try: + # Get the existing event + event = self.service.events().get( + calendarId=self.calendar_id, + eventId=event_id + ).execute() + + # Update the description with results + description = event.get('description', '') + + # Check if results are already in the description + if '🏆 RESULT:' in description: + # Update existing result + description = re.sub( + r'🏆 RESULT:.*?\n.*?Winner:.*?\n', + f"🏆 RESULT: {match_data.get('score', 'Unknown')}\nWinner: {match_data.get('winner', 'Unknown')}\n", + description, + flags=re.DOTALL + ) + else: + # Add new result + result_text = f"\n🏆 RESULT: {match_data.get('score', 'Unknown')}\nWinner: {match_data.get('winner', 'Unknown')}\n" + if 'ID:' in description: + description = description.replace('ID:', result_text + 'ID:') + else: + description += result_text + + # Update the summary to show it's completed with result + summary = event.get('summary', '') + if '✓' not in summary: + # Add checkmark and score to the title + summary = f"✓ {summary} ({match_data.get('score', '?-?')})" + + # Update the event + event['description'] = description + event['summary'] = summary + + updated_event = self.service.events().update( + calendarId=self.calendar_id, + eventId=event_id, + body=event + ).execute() + + return True + + except Exception as e: + print(f"Error updating event: {e}") + return False + + def check_time_difference(self, event_datetime, new_datetime): + """Check if there's a significant time difference (>= 5 minutes)""" + # Parse event datetime + if isinstance(event_datetime, str): + if event_datetime.endswith('Z'): + event_dt = datetime.fromisoformat(event_datetime.replace('Z', '+00:00')) + else: + event_dt = datetime.fromisoformat(event_datetime) + else: + event_dt = event_datetime + + # Ensure timezone aware + if event_dt.tzinfo is None: + event_dt = event_dt.replace(tzinfo=pytz.UTC) + if new_datetime.tzinfo is None: + new_datetime = new_datetime.replace(tzinfo=pytz.UTC) + + # Calculate difference in minutes + diff = abs((event_dt - new_datetime).total_seconds() / 60) + + return diff >= 5 # Return True if difference is 5 minutes or more + + def sync_matches_to_calendar(self, dry_run=False, update_results=True, update_times=True): + """Main sync function with time updates""" + print("\n" + "="*50) + print("Starting Dota 2 Calendar Sync v3") + print("="*50 + "\n") + + # Fetch all matches + upcoming_matches, completed_matches = self.fetch_all_matches() + + if not upcoming_matches and not completed_matches: + print("No matches found to sync") + return + + # Get existing events + existing_events = self.get_existing_events(days_back=7, days_ahead=30) + + # Counters + added_count = 0 + skipped_count = 0 + updated_count = 0 + time_updated_count = 0 + error_count = 0 + + # Process upcoming matches + print("\nProcessing upcoming matches...") + print("-" * 30) + + now = datetime.now(pytz.UTC) + future_matches = [m for m in upcoming_matches if m.get('datetime', now) >= now] + + for match in future_matches: + match_id = match.get('id') + team1 = match.get('team1', 'TBD') + team2 = match.get('team2', 'TBD') + match_time = match.get('datetime', now) + tournament = match.get('tournament', '') + + if not match_id: + continue + + # Try to find existing event by ID or by match details + existing_event = None + + # First try by ID + if match_id in existing_events: + existing_event = existing_events[match_id] + + # If not found, try by match details (teams + tournament) + if not existing_event and '_by_match' in existing_events: + match_key = f"{team1}_{team2}_{tournament}" + if match_key in existing_events['_by_match']: + existing_event = existing_events['_by_match'][match_key] + + if existing_event: + # Check if time has changed + if update_times: + event_start = existing_event['start'].get('dateTime', existing_event['start'].get('date')) + + if self.check_time_difference(event_start, match_time): + # Time has changed + if dry_run: + old_time = datetime.fromisoformat(event_start.replace('Z', '+00:00')) + print(f"◯ Would update time: {team1} vs {team2}") + print(f" Old: {old_time.strftime('%Y-%m-%d %H:%M UTC')}") + print(f" New: {match_time.strftime('%Y-%m-%d %H:%M UTC')}") + time_updated_count += 1 + else: + old_time = datetime.fromisoformat(event_start.replace('Z', '+00:00')) + if self.update_event_time(existing_event['id'], match_time): + print(f"⏰ Updated time: {team1} vs {team2}") + print(f" Old: {old_time.strftime('%Y-%m-%d %H:%M UTC')}") + print(f" New: {match_time.strftime('%Y-%m-%d %H:%M UTC')}") + time_updated_count += 1 + time.sleep(0.2) + else: + print(f"✗ Failed to update time: {team1} vs {team2}") + error_count += 1 + else: + print(f"⊘ No change: {team1} vs {team2}") + skipped_count += 1 + else: + print(f"⊘ Skipping (exists): {team1} vs {team2}") + skipped_count += 1 + else: + # New match + if dry_run: + print(f"◯ Would add: {team1} vs {team2} at {match_time.strftime('%Y-%m-%d %H:%M UTC')}") + added_count += 1 + else: + try: + event = self.create_calendar_event(match) + self.service.events().insert( + calendarId=self.calendar_id, + body=event + ).execute() + print(f"✓ Added: {team1} vs {team2} at {match_time.strftime('%Y-%m-%d %H:%M UTC')}") + added_count += 1 + time.sleep(0.2) + except Exception as e: + print(f"✗ Error adding {team1} vs {team2}: {e}") + error_count += 1 + + # Process completed matches for results + if update_results and completed_matches: + print("\nProcessing completed match results...") + print("-" * 30) + + for match in completed_matches: + match_id = match.get('id') + team1 = match.get('team1', 'TBD') + team2 = match.get('team2', 'TBD') + score = match.get('score', 'Unknown') + tournament = match.get('tournament', '') + + if not match_id: + continue + + # Try to find existing event by ID or by match details + existing_event = None + + # First try by ID + if match_id in existing_events: + existing_event = existing_events[match_id] + + # If not found, try by match details + if not existing_event and '_by_match' in existing_events: + match_key = f"{team1}_{team2}_{tournament}" + if match_key in existing_events['_by_match']: + existing_event = existing_events['_by_match'][match_key] + + if existing_event: + # Check if already marked as completed + summary = existing_event.get('summary', '') + if '✓' in summary or '[COMPLETED]' in summary: + print(f"⊘ Already updated: {team1} vs {team2} ({score})") + else: + if dry_run: + print(f"◯ Would update result: {team1} vs {team2} - {score}") + updated_count += 1 + else: + if self.update_event_with_result(existing_event['id'], match): + print(f"✓ Updated result: {team1} vs {team2} - {score}") + updated_count += 1 + time.sleep(0.2) + else: + print(f"✗ Failed to update: {team1} vs {team2}") + error_count += 1 + + # Summary + print("\n" + "="*50) + print("Sync Summary") + print("="*50) + print(f"✓ Added: {added_count} matches") + if time_updated_count > 0: + print(f"⏰ Time updated: {time_updated_count} matches") + if updated_count > 0: + print(f"✓ Results updated: {updated_count} matches") + print(f"⊘ Skipped: {skipped_count} matches (no changes)") + if error_count > 0: + print(f"✗ Errors: {error_count} matches") + + if dry_run: + print("\n⚠ DRY RUN - No actual changes were made") + + print("\n✓ Sync complete!") + +def main(): + parser = argparse.ArgumentParser( + description='Sync Dota 2 Tier 1 matches from Liquipedia to Google Calendar v3' + ) + parser.add_argument( + '--calendar-id', + default='primary', + help='Google Calendar ID (default: primary).' + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='Perform a dry run without actually creating/updating events' + ) + parser.add_argument( + '--no-results', + action='store_true', + help='Skip updating completed match results' + ) + parser.add_argument( + '--no-time-updates', + action='store_true', + help='Skip updating match times' + ) + parser.add_argument( + '--credentials', + default='credentials.json', + help='Path to Google service account credentials JSON file' + ) + + args = parser.parse_args() + + # Notice + print("\n" + "!"*60) + print("Dota 2 Calendar Sync v3") + print("Features: Match sync, result updates, time change detection") + print("Service Account: calendar-bot@tunpok.iam.gserviceaccount.com") + print("!"*60 + "\n") + + # Initialize and run sync + try: + sync = Dota2CalendarSync( + credentials_file=args.credentials, + calendar_id=args.calendar_id + ) + + sync.sync_matches_to_calendar( + dry_run=args.dry_run, + update_results=not args.no_results, + update_times=not args.no_time_updates + ) + + except KeyboardInterrupt: + print("\n\nSync cancelled by user") + sys.exit(0) + except Exception as e: + print(f"\n✗ Fatal error: {e}") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file