Compare commits
2 Commits
2ee2d87238
...
dc63c9bd01
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc63c9bd01 | ||
|
|
a1573a3f82 |
173
.gitignore
vendored
173
.gitignore
vendored
@ -1,14 +1,12 @@
|
|||||||
# ---> Python
|
# Python
|
||||||
# Byte-compiled / optimized / DLL files
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
|
|
||||||
# C extensions
|
|
||||||
*.so
|
*.so
|
||||||
|
|
||||||
# Distribution / packaging
|
|
||||||
.Python
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
build/
|
build/
|
||||||
develop-eggs/
|
develop-eggs/
|
||||||
dist/
|
dist/
|
||||||
@ -21,150 +19,47 @@ parts/
|
|||||||
sdist/
|
sdist/
|
||||||
var/
|
var/
|
||||||
wheels/
|
wheels/
|
||||||
share/python-wheels/
|
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.installed.cfg
|
.installed.cfg
|
||||||
*.egg
|
*.egg
|
||||||
MANIFEST
|
|
||||||
|
|
||||||
# PyInstaller
|
# Virtual Environments
|
||||||
# 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
|
|
||||||
.venv
|
.venv
|
||||||
env/
|
|
||||||
venv/
|
venv/
|
||||||
ENV/
|
ENV/
|
||||||
env.bak/
|
env/
|
||||||
venv.bak/
|
|
||||||
|
|
||||||
# Spyder project settings
|
# IDE
|
||||||
.spyderproject
|
.vscode/
|
||||||
.spyproject
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
# Rope project settings
|
# Credentials and sensitive data
|
||||||
.ropeproject
|
credentials.json
|
||||||
|
token.json
|
||||||
|
client_secret*.json
|
||||||
|
|
||||||
# mkdocs documentation
|
# Test and temporary files
|
||||||
/site
|
*.log
|
||||||
|
*.tmp
|
||||||
|
test_*.html
|
||||||
|
liquipedia_page.html
|
||||||
|
|
||||||
# mypy
|
# Cache
|
||||||
.mypy_cache/
|
.cache/
|
||||||
.dmypy.json
|
*.cache
|
||||||
dmypy.json
|
|
||||||
|
|
||||||
# Pyre type checker
|
# Environment variables
|
||||||
.pyre/
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
# pytype static type analyzer
|
# Backup files
|
||||||
.pytype/
|
*.bak
|
||||||
|
*.backup
|
||||||
# Cython debug symbols
|
*_backup.py
|
||||||
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/
|
|
||||||
|
|
||||||
|
# Calendar ID specific files (if needed to keep private)
|
||||||
|
# calendar_config.json
|
||||||
48
CHANGELOG.md
Normal file
48
CHANGELOG.md
Normal file
@ -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`
|
||||||
|
- 现在: `✓ 2-0 Team1 vs Team2 [Tournament]`
|
||||||
|
- 移除了 "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"
|
||||||
|
```
|
||||||
125
README.md
125
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
|
||||||
57
TIMEZONE_INFO.md
Normal file
57
TIMEZONE_INFO.md
Normal file
@ -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 会根据你的时区设置自动显示正确时间
|
||||||
|
✅ **无需调整** - 脚本已正确处理时区转换
|
||||||
519
legacy/sync_dota2_matches.py
Normal file
519
legacy/sync_dota2_matches.py
Normal file
@ -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()
|
||||||
564
legacy/sync_dota2_matches_v2.py
Normal file
564
legacy/sync_dota2_matches_v2.py
Normal file
@ -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()
|
||||||
8
requirements.txt
Normal file
8
requirements.txt
Normal file
@ -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
|
||||||
24
run_sync.sh
Executable file
24
run_sync.sh
Executable file
@ -0,0 +1,24 @@
|
|||||||
|
#!/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 ""
|
||||||
|
echo "标题格式说明:"
|
||||||
|
echo " 未开始: Team1 vs Team2 [Tournament]"
|
||||||
|
echo " 已完成: ✓ 2-0 Team1 vs Team2 [Tournament]"
|
||||||
701
sync_dota2_matches.py
Normal file
701
sync_dota2_matches.py
Normal file
@ -0,0 +1,701 @@
|
|||||||
|
#!/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 and scores
|
||||||
|
summary = summary.replace('[COMPLETED] ', '')
|
||||||
|
# Remove checkmark and score (format: "✓ 2-1 Team vs Team")
|
||||||
|
summary = re.sub(r'^✓\s+\d+[-:]\d+\s+', '', summary)
|
||||||
|
# Also handle old format with score at end
|
||||||
|
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 right after it
|
||||||
|
score = match_data.get('score', '?-?')
|
||||||
|
summary = f"✓ {score} {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 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()
|
||||||
Loading…
x
Reference in New Issue
Block a user