#!/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())