- 放宽TBD事件匹配时间窗口从5分钟到1小时 - 新增自动删除过期TBD事件功能(超过2小时的过期事件) - 删除不再需要的清理脚本 (cleanup_duplicates.py, delete_duplicates.py) - 更新文档说明新功能 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
f279bc16f3
commit
56a79c8f9d
13
CHANGELOG.md
13
CHANGELOG.md
@ -1,5 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
## v3.6 - 改进TBD比赛处理机制
|
||||
- **放宽TBD比赛时间匹配条件**:
|
||||
- 将TBD事件匹配的时间窗口从5分钟扩大到1小时
|
||||
- 更好地处理比赛时间调整的情况
|
||||
- 添加时间差异显示,方便调试
|
||||
- **自动删除过期TBD事件**:
|
||||
- 新增 `delete_calendar_event()` 方法
|
||||
- 自动检测并删除超过2小时的过期TBD事件
|
||||
- 避免日历中积累无效的占位事件
|
||||
- **增强的TBD事件管理**:
|
||||
- 跟踪已更新的TBD事件,避免误删
|
||||
- 在同步摘要中显示删除的TBD事件数量
|
||||
|
||||
## v3.5 - 修复TBD比赛标记问题
|
||||
- **修复TBD比赛错误标记为完成**:
|
||||
- 修改比分解析逻辑,只匹配破折号而不匹配冒号
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# Dota 2 Calendar Sync v3.5
|
||||
# Dota 2 Calendar Sync v3.6
|
||||
|
||||
自动从 Liquipedia 获取 Dota 2 Tier 1 比赛信息并同步到 Google Calendar,支持自动更新比赛结果和时间变更。
|
||||
自动从 Liquipedia 获取 Dota 2 Tier 1 比赛信息并同步到 Google Calendar,支持自动更新比赛结果、时间变更和智能管理TBD占位事件。
|
||||
|
||||
## 功能
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
- 自动创建 Google Calendar 事件
|
||||
- **自动更新已完成比赛的结果和比分**
|
||||
- **检测并更新比赛时间变更**(赛程调整时自动同步)
|
||||
- **智能管理TBD占位事件**(自动更新队伍信息,删除过期事件)
|
||||
- 避免重复添加已存在的比赛
|
||||
- 支持 dry-run 模式进行测试
|
||||
|
||||
@ -82,6 +83,7 @@ python sync_dota2_matches.py --dry-run
|
||||
- 提取比赛格式(Bo1、Bo3、Bo5)
|
||||
- **智能去重**:相同时间、相同轮次的 TBD 比赛只保留一个代表
|
||||
- **TBD比赛保护**:确保TBD vs TBD的比赛不会被错误标记为已完成
|
||||
- **改进的TBD匹配**:放宽时间匹配窗口至1小时,更好处理赛程调整
|
||||
|
||||
2. **日历事件管理**:
|
||||
- 自动设置比赛时长(根据 Bo 格式估算)
|
||||
@ -91,6 +93,7 @@ python sync_dota2_matches.py --dry-run
|
||||
- **自动更新已完成比赛的结果**
|
||||
- **在标题添加完成标记(✓ + 比分)**
|
||||
- **TBD 比赛队伍确定后自动更新**
|
||||
- **自动清理过期的TBD占位事件**
|
||||
|
||||
3. **错误处理**:
|
||||
- 网络请求超时处理
|
||||
|
||||
@ -1,241 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Cleanup duplicate calendar events
|
||||
Finds and optionally removes duplicate Dota 2 match events
|
||||
"""
|
||||
|
||||
import requests
|
||||
from google.oauth2 import service_account
|
||||
from googleapiclient.discovery import build
|
||||
from datetime import datetime, timedelta
|
||||
import pytz
|
||||
import re
|
||||
import sys
|
||||
import argparse
|
||||
|
||||
def authenticate(credentials_file='credentials.json'):
|
||||
"""Authenticate with Google Calendar using service account credentials"""
|
||||
try:
|
||||
credentials = service_account.Credentials.from_service_account_file(
|
||||
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 find_duplicates(service, calendar_id='primary', days_back=7, days_ahead=30):
|
||||
"""Find duplicate events in the 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"Scanning calendar from {days_back} days ago to {days_ahead} days ahead...")
|
||||
|
||||
events_result = service.events().list(
|
||||
calendarId=calendar_id,
|
||||
timeMin=time_min,
|
||||
timeMax=time_max,
|
||||
maxResults=500,
|
||||
singleEvents=True,
|
||||
orderBy='startTime'
|
||||
).execute()
|
||||
|
||||
events = events_result.get('items', [])
|
||||
|
||||
# Group events by match key (teams + tournament + time window)
|
||||
matches = {}
|
||||
|
||||
for event in events:
|
||||
summary = event.get('summary', '')
|
||||
|
||||
# Skip non-Dota events
|
||||
if 'vs' not in summary:
|
||||
continue
|
||||
|
||||
# Extract teams and tournament
|
||||
# Remove score and checkmark
|
||||
clean_summary = re.sub(r'^✓?\s*\d+[-:]\d+\s*', '', summary)
|
||||
clean_summary = re.sub(r'\[COMPLETED\]\s*', '', clean_summary)
|
||||
|
||||
# Extract teams
|
||||
match = re.search(r'^(.*?)\s+vs\s+(.*?)(?:\s*\[(.*?)\])?$', clean_summary)
|
||||
if not match:
|
||||
continue
|
||||
|
||||
team1 = match.group(1).strip()
|
||||
team2 = match.group(2).strip()
|
||||
tournament = match.group(3).strip() if match.group(3) else ''
|
||||
|
||||
# Get event time
|
||||
event_start = event['start'].get('dateTime', event['start'].get('date'))
|
||||
event_dt = datetime.fromisoformat(event_start.replace('Z', '+00:00'))
|
||||
|
||||
# Create match key (teams + tournament + time rounded to hour)
|
||||
time_key = event_dt.strftime('%Y-%m-%d-%H')
|
||||
match_key = f"{sorted([team1, team2])}_{tournament}_{time_key}"
|
||||
|
||||
if match_key not in matches:
|
||||
matches[match_key] = []
|
||||
|
||||
matches[match_key].append({
|
||||
'id': event['id'],
|
||||
'summary': summary,
|
||||
'start': event_start,
|
||||
'team1': team1,
|
||||
'team2': team2,
|
||||
'tournament': tournament,
|
||||
'created': event.get('created', ''),
|
||||
'updated': event.get('updated', ''),
|
||||
'description': event.get('description', '')
|
||||
})
|
||||
|
||||
# Find duplicates
|
||||
duplicates = {}
|
||||
for match_key, events in matches.items():
|
||||
if len(events) > 1:
|
||||
duplicates[match_key] = events
|
||||
|
||||
return duplicates
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error scanning calendar: {e}")
|
||||
return {}
|
||||
|
||||
def display_duplicates(duplicates):
|
||||
"""Display found duplicates"""
|
||||
if not duplicates:
|
||||
print("\n✓ No duplicate events found!")
|
||||
return
|
||||
|
||||
print(f"\n⚠️ Found {len(duplicates)} sets of duplicate events:")
|
||||
print("=" * 80)
|
||||
|
||||
for match_key, events in duplicates.items():
|
||||
print(f"\nDuplicate set: {len(events)} events")
|
||||
print("-" * 40)
|
||||
|
||||
for i, event in enumerate(events, 1):
|
||||
print(f"\nEvent #{i}:")
|
||||
print(f" Summary: {event['summary']}")
|
||||
print(f" Teams: {event['team1']} vs {event['team2']}")
|
||||
print(f" Tournament: {event['tournament']}")
|
||||
print(f" Time: {event['start'][:19]}")
|
||||
print(f" Event ID: {event['id']}")
|
||||
print(f" Created: {event['created'][:19] if event['created'] else 'N/A'}")
|
||||
print(f" Updated: {event['updated'][:19] if event['updated'] else 'N/A'}")
|
||||
|
||||
# Check if has result
|
||||
if '✓' in event['summary'] or '🏆 RESULT' in event['description']:
|
||||
print(f" Status: COMPLETED")
|
||||
elif '📊 CURRENT SCORE' in event['description']:
|
||||
print(f" Status: IN PROGRESS")
|
||||
else:
|
||||
print(f" Status: UPCOMING")
|
||||
|
||||
def remove_duplicates(service, duplicates, calendar_id='primary', dry_run=True):
|
||||
"""Remove duplicate events, keeping the most recently updated one"""
|
||||
if not duplicates:
|
||||
return
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("Duplicate Removal Process")
|
||||
print("=" * 80)
|
||||
|
||||
removed_count = 0
|
||||
|
||||
for match_key, events in duplicates.items():
|
||||
print(f"\nProcessing duplicate set with {len(events)} events...")
|
||||
|
||||
# Sort by updated time (keep most recent)
|
||||
events.sort(key=lambda x: x.get('updated', x.get('created', '')), reverse=True)
|
||||
|
||||
# Keep the first (most recent) event
|
||||
keep_event = events[0]
|
||||
remove_events = events[1:]
|
||||
|
||||
print(f" Keeping: {keep_event['summary']} (updated: {keep_event['updated'][:19] if keep_event['updated'] else 'N/A'})")
|
||||
|
||||
for event in remove_events:
|
||||
if dry_run:
|
||||
print(f" ◯ Would remove: {event['summary']} (ID: {event['id'][:20]}...)")
|
||||
removed_count += 1
|
||||
else:
|
||||
try:
|
||||
service.events().delete(
|
||||
calendarId=calendar_id,
|
||||
eventId=event['id']
|
||||
).execute()
|
||||
print(f" ✓ Removed: {event['summary']} (ID: {event['id'][:20]}...)")
|
||||
removed_count += 1
|
||||
except Exception as e:
|
||||
print(f" ✗ Failed to remove: {event['summary']} - {e}")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print(f"Summary: {'Would remove' if dry_run else 'Removed'} {removed_count} duplicate events")
|
||||
if dry_run:
|
||||
print("⚠️ DRY RUN - No actual changes made. Use --remove to actually remove duplicates")
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Find and remove duplicate Dota 2 calendar events'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--calendar-id',
|
||||
default='primary',
|
||||
help='Google Calendar ID (default: primary)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--remove',
|
||||
action='store_true',
|
||||
help='Actually remove duplicates (default is dry-run)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--days-back',
|
||||
type=int,
|
||||
default=7,
|
||||
help='Days to look back (default: 7)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--days-ahead',
|
||||
type=int,
|
||||
default=30,
|
||||
help='Days to look ahead (default: 30)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--credentials',
|
||||
default='credentials.json',
|
||||
help='Path to Google service account credentials JSON file'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Authenticate
|
||||
service = authenticate(args.credentials)
|
||||
|
||||
# Find duplicates
|
||||
duplicates = find_duplicates(
|
||||
service,
|
||||
calendar_id=args.calendar_id,
|
||||
days_back=args.days_back,
|
||||
days_ahead=args.days_ahead
|
||||
)
|
||||
|
||||
# Display duplicates
|
||||
display_duplicates(duplicates)
|
||||
|
||||
# Remove duplicates if requested
|
||||
if duplicates:
|
||||
remove_duplicates(
|
||||
service,
|
||||
duplicates,
|
||||
calendar_id=args.calendar_id,
|
||||
dry_run=not args.remove
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,120 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Delete duplicate calendar events based on screenshot
|
||||
Manually delete the duplicate events that were created during live score updates
|
||||
"""
|
||||
|
||||
from google.oauth2 import service_account
|
||||
from googleapiclient.discovery import build
|
||||
from datetime import datetime, timedelta
|
||||
import pytz
|
||||
import sys
|
||||
|
||||
def authenticate(credentials_file='credentials.json'):
|
||||
"""Authenticate with Google Calendar using service account credentials"""
|
||||
try:
|
||||
credentials = service_account.Credentials.from_service_account_file(
|
||||
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 delete_specific_events(service, calendar_id='primary'):
|
||||
"""Delete specific duplicate events based on partial scores"""
|
||||
|
||||
# Based on the screenshot, these are the duplicate events to look for:
|
||||
duplicates_to_find = [
|
||||
("1-0", "NGX", "Liquid"), # 19:00 1-0 NGX vs Liquid
|
||||
("1-1", "NGX", "Liquid"), # 19:45 1-1 NGX vs Liquid
|
||||
]
|
||||
|
||||
# Get events for Sept 5
|
||||
target_date = datetime(2025, 9, 5, tzinfo=pytz.UTC)
|
||||
time_min = target_date.isoformat()
|
||||
time_max = (target_date + timedelta(days=1)).isoformat()
|
||||
|
||||
events_result = service.events().list(
|
||||
calendarId=calendar_id,
|
||||
timeMin=time_min,
|
||||
timeMax=time_max,
|
||||
maxResults=500,
|
||||
singleEvents=True,
|
||||
orderBy='startTime'
|
||||
).execute()
|
||||
|
||||
events = events_result.get('items', [])
|
||||
|
||||
print(f"\nScanning {len(events)} events on September 5th...")
|
||||
print("=" * 60)
|
||||
|
||||
events_to_delete = []
|
||||
|
||||
for event in events:
|
||||
summary = event.get('summary', '')
|
||||
|
||||
# Check if this matches any of our duplicate patterns
|
||||
for score, team1, team2 in duplicates_to_find:
|
||||
if score in summary and team1 in summary and team2 in summary and '✓' not in summary:
|
||||
events_to_delete.append({
|
||||
'id': event['id'],
|
||||
'summary': summary,
|
||||
'start': event['start'].get('dateTime', event['start'].get('date'))
|
||||
})
|
||||
print(f"Found duplicate: {summary}")
|
||||
print(f" Time: {event['start'].get('dateTime', event['start'].get('date'))}")
|
||||
print(f" ID: {event['id']}")
|
||||
break
|
||||
|
||||
if not events_to_delete:
|
||||
print("\n❌ No duplicate events found in API.")
|
||||
print("This could mean:")
|
||||
print("1. The duplicates are in a different calendar")
|
||||
print("2. They have already been deleted")
|
||||
print("3. The calendar interface is showing cached data")
|
||||
print("\nTry refreshing your browser (Ctrl+F5 or Cmd+Shift+R)")
|
||||
return
|
||||
|
||||
print(f"\n⚠️ Found {len(events_to_delete)} duplicate events to delete")
|
||||
print("-" * 60)
|
||||
|
||||
# Confirm before deleting
|
||||
print("\nThese events will be deleted:")
|
||||
for event in events_to_delete:
|
||||
print(f" - {event['summary']} at {event['start'][:19]}")
|
||||
|
||||
response = input("\nDo you want to delete these events? (yes/no): ")
|
||||
|
||||
if response.lower() == 'yes':
|
||||
deleted_count = 0
|
||||
for event in events_to_delete:
|
||||
try:
|
||||
service.events().delete(
|
||||
calendarId=calendar_id,
|
||||
eventId=event['id']
|
||||
).execute()
|
||||
print(f"✓ Deleted: {event['summary']}")
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to delete {event['summary']}: {e}")
|
||||
|
||||
print(f"\n✓ Successfully deleted {deleted_count} duplicate events")
|
||||
else:
|
||||
print("\n❌ Deletion cancelled")
|
||||
|
||||
def main():
|
||||
# Authenticate
|
||||
service = authenticate()
|
||||
|
||||
# Delete specific duplicates
|
||||
delete_specific_events(service)
|
||||
|
||||
print("\nPlease refresh your Google Calendar to see the changes.")
|
||||
print("If you still see duplicates, they might be in a different calendar.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -695,7 +695,19 @@ class Dota2CalendarSync:
|
||||
|
||||
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):
|
||||
def delete_calendar_event(self, event_id):
|
||||
"""Delete a calendar event"""
|
||||
try:
|
||||
self.service.events().delete(
|
||||
calendarId=self.calendar_id,
|
||||
eventId=event_id
|
||||
).execute()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error deleting event: {e}")
|
||||
return False
|
||||
|
||||
def sync_matches_to_calendar(self, dry_run=False, update_results=True, update_times=True, delete_old_tbd=True):
|
||||
"""Main sync function with time updates"""
|
||||
print("\n" + "="*50)
|
||||
print("Starting Dota 2 Calendar Sync v3")
|
||||
@ -717,6 +729,10 @@ class Dota2CalendarSync:
|
||||
updated_count = 0
|
||||
time_updated_count = 0
|
||||
error_count = 0
|
||||
deleted_tbd_count = 0
|
||||
|
||||
# Track which TBD events were updated
|
||||
updated_tbd_events = set()
|
||||
|
||||
# Process upcoming matches
|
||||
print("\nProcessing upcoming matches...")
|
||||
@ -770,9 +786,11 @@ class Dota2CalendarSync:
|
||||
# Check if time matches
|
||||
event_start = event['start'].get('dateTime', event['start'].get('date'))
|
||||
event_dt = datetime.fromisoformat(event_start.replace('Z', '+00:00'))
|
||||
if abs((event_dt - match_time).total_seconds()) < 300: # Within 5 minutes
|
||||
# Relaxed time matching: within 1 hour (3600 seconds)
|
||||
if abs((event_dt - match_time).total_seconds()) < 3600: # Within 1 hour
|
||||
existing_event = event
|
||||
print(f" → Found TBD match to update: {team1} vs {team2}")
|
||||
print(f" Time difference: {abs((event_dt - match_time).total_seconds())/60:.0f} minutes")
|
||||
break
|
||||
|
||||
if existing_event:
|
||||
@ -789,6 +807,7 @@ class Dota2CalendarSync:
|
||||
if self.update_event_with_teams(existing_event['id'], match):
|
||||
print(f"✓ Updated TBD match with teams: {team1} vs {team2}")
|
||||
updated_count += 1
|
||||
updated_tbd_events.add(existing_event['id'])
|
||||
time.sleep(0.2)
|
||||
else:
|
||||
print(f"✗ Failed to update TBD match: {team1} vs {team2}")
|
||||
@ -941,6 +960,42 @@ class Dota2CalendarSync:
|
||||
print(f"✗ Failed to update: {team1} vs {team2}")
|
||||
error_count += 1
|
||||
|
||||
# Delete old TBD events that are past and not updated
|
||||
if delete_old_tbd and not dry_run:
|
||||
print("\nChecking for expired TBD events to delete...")
|
||||
print("-" * 30)
|
||||
|
||||
# Get all TBD events again to check which ones to delete
|
||||
for key, event in existing_events.items():
|
||||
if key == '_by_match':
|
||||
continue
|
||||
|
||||
summary = event.get('summary', '')
|
||||
if 'TBD vs TBD' in summary:
|
||||
event_id = event['id']
|
||||
|
||||
# Skip if this event was updated
|
||||
if event_id in updated_tbd_events:
|
||||
continue
|
||||
|
||||
# Check if event is in the past
|
||||
event_start = event['start'].get('dateTime', event['start'].get('date'))
|
||||
event_dt = datetime.fromisoformat(event_start.replace('Z', '+00:00'))
|
||||
|
||||
# If event is more than 2 hours in the past, delete it
|
||||
if event_dt < now - timedelta(hours=2):
|
||||
if dry_run:
|
||||
print(f"◯ Would delete expired TBD event: {summary} ({event_dt.strftime('%Y-%m-%d %H:%M UTC')})")
|
||||
deleted_tbd_count += 1
|
||||
else:
|
||||
if self.delete_calendar_event(event_id):
|
||||
print(f"🗑️ Deleted expired TBD event: {summary} ({event_dt.strftime('%Y-%m-%d %H:%M UTC')})")
|
||||
deleted_tbd_count += 1
|
||||
time.sleep(0.2)
|
||||
else:
|
||||
print(f"✗ Failed to delete TBD event: {summary}")
|
||||
error_count += 1
|
||||
|
||||
# Summary
|
||||
print("\n" + "="*50)
|
||||
print("Sync Summary")
|
||||
@ -950,6 +1005,8 @@ class Dota2CalendarSync:
|
||||
print(f"⏰ Time updated: {time_updated_count} matches")
|
||||
if updated_count > 0:
|
||||
print(f"✓ Results updated: {updated_count} matches")
|
||||
if deleted_tbd_count > 0:
|
||||
print(f"🗑️ Deleted: {deleted_tbd_count} expired TBD events")
|
||||
print(f"⊘ Skipped: {skipped_count} matches (no changes)")
|
||||
if error_count > 0:
|
||||
print(f"✗ Errors: {error_count} matches")
|
||||
@ -1008,7 +1065,8 @@ def main():
|
||||
sync.sync_matches_to_calendar(
|
||||
dry_run=args.dry_run,
|
||||
update_results=not args.no_results,
|
||||
update_times=not args.no_time_updates
|
||||
update_times=not args.no_time_updates,
|
||||
delete_old_tbd=True
|
||||
)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user