Files
youlu-openclaw-workspace/scripts/ucla_pilates_monitor.py

198 lines
7.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
UCLA Reformer Pilates Course Monitor - Date-aware Version
Only reports courses that are NOT "Full" AND not yet started/expired
"""
import asyncio
import re
from datetime import datetime
from playwright.async_api import async_playwright
# Course URLs to monitor
COURSES = {
"Reformer Pilates (Enrolled)": "https://secure.recreation.ucla.edu/Program/GetProgramDetails?courseId=d7adf66a-d3a6-46d6-96c7-54e4c015dcf1",
"Reformer Pilates (Standby)": "https://secure.recreation.ucla.edu/Program/GetProgramDetails?courseId=7abbf877-f1cf-4ddc-a0ef-690ff935b39a"
}
# Sections to exclude (time doesn't work for us)
EXCLUDE_SECTIONS = [
"Sec 16B", # Wednesday 12:00pm - not available
"Sec 19B", # Friday 12:00pm - not available
]
def should_exclude(text):
"""Check if course should be excluded based on section/time"""
for exclude in EXCLUDE_SECTIONS:
if exclude in text:
return True
return False
def parse_date_range(text):
"""Extract date range from course text like (1/5-2/6) or (2/13-3/13)"""
# Match patterns like (1/5-2/6) or (2/13-3/13)
match = re.search(r'\((\d{1,2})/(\d{1,2})-(\d{1,2})/(\d{1,2})\)', text)
if match:
start_month, start_day, end_month, end_day = match.groups()
current_year = datetime.now().year
try:
start_date = datetime(current_year, int(start_month), int(start_day))
end_date = datetime(current_year, int(end_month), int(end_day))
return start_date, end_date
except ValueError:
return None, None
return None, None
def is_course_active(start_date, end_date):
"""Check if course is still active (not yet ended)"""
if not end_date:
return True # Can't parse date, assume active
today = datetime.now()
# Course is active if it hasn't ended yet (give 1 day buffer)
return end_date >= today
def is_valid_course_entry(text):
"""Check if text is a valid course entry (not description/no-offering text)"""
text_lower = text.lower()
# Exclude these patterns
exclude_patterns = [
"there are no offerings available",
"to view the class times",
"please visit the",
"this standby pass is valid",
"instructor:",
"reformer pilates - standby pass", # Header text
"×", # Close button
]
for pattern in exclude_patterns:
if pattern in text_lower:
return False
# Must contain course identifier (Sec X or Session)
has_course_id = bool(re.search(r'(Sec \d+[A-Z]|Session [A-Z])', text))
# Must contain price or day/time info
has_info = bool(re.search(r'(\$\d+|[MTWTF]{1,2},? \d{1,2}:\d{2})', text))
return has_course_id and has_info
async def check_course(page, name, url):
"""Check a single course page, return available sections"""
available = []
try:
await page.goto(url, wait_until="networkidle", timeout=30000)
await page.wait_for_selector("text=Offerings", timeout=10000)
# Get all semester tabs
semesters = await page.query_selector_all("[role='tab']")
for semester in semesters:
sem_name = await semester.inner_text()
sem_name = sem_name.strip()
await semester.click()
await page.wait_for_timeout(1000)
# Find all course sections
sections = await page.query_selector_all(".offering-item, [class*='offering'], .card, .list-group-item, tr")
for section in sections:
try:
text = await section.inner_text()
if not text or len(text) < 30:
continue
text_lower = text.lower()
# Check if it's NOT full
# NOTE: Spring 2026 courses currently show "7 Spots Left" but
# "registration not open" appears only after login. Real openings
# will show < 7 spots as people start enrolling. Report when
# spot count decreases from the default 7.
is_full = "full" in text_lower
if is_full:
continue
# Check if it's a valid course entry
if not is_valid_course_entry(text):
continue
# Check if excluded (time doesn't work)
if should_exclude(text):
continue
# Check date range
start_date, end_date = parse_date_range(text)
if not is_course_active(start_date, end_date):
continue # Course has ended
# Extract clean info
# Remove extra whitespace and truncate
lines = [line.strip() for line in text.strip().split('\n') if line.strip()]
info = ' | '.join(lines[:3]) # First 3 lines max
info = info[:200] # Limit length
# Format dates nicely
if start_date and end_date:
date_str = f"{start_date.strftime('%m/%d')}-{end_date.strftime('%m/%d')}"
else:
date_str = ""
available.append({
'semester': sem_name,
'info': info,
'dates': date_str,
'start_date': start_date,
'end_date': end_date
})
except Exception:
continue
except Exception as e:
return [{'error': f"Error checking {name}: {e}"}]
return available
async def main():
"""Main function - only output available and active courses"""
all_available = []
today_str = datetime.now().strftime("%Y-%m-%d %H:%M")
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
await page.set_viewport_size({"width": 1280, "height": 800})
for name, url in COURSES.items():
available = await check_course(page, name, url)
if available and not any('error' in str(item) for item in available):
all_available.append((name, available))
await browser.close()
# Only print if there are available courses
if all_available:
print(f"🚨 UCLA Pilates - Available Courses ({today_str})")
print("=" * 60)
for name, courses in all_available:
print(f"\n📋 {name}:")
for course in courses:
# Format: [Winter 2026] 📅 02/11-03/11
date_str = f"📅 {course['dates']}" if course['dates'] else ""
print(f" ✅ [{course['semester']}] {date_str}")
print(f" {course['info']}")
print("\n" + "=" * 60)
print("👉 Enroll at: https://secure.recreation.ucla.edu")
else:
# No available courses - silent
pass
if __name__ == "__main__":
asyncio.run(main())