194 lines
7.0 KiB
Python
194 lines
7.0 KiB
Python
#!/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
|
||
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())
|