# Native modules import logging import traceback import os import time from datetime import datetime, timedelta, date # Third-party modules import requests from dateutil.parser import parse import pytz from dotenv import load_dotenv from urllib.parse import urlencode from typing import List, Dict, Optional, Any, Tuple # Import the SessionNotifier class from session_notifier import SessionNotifier # Import the preferred sessions from the session_config module from session_config import PREFERRED_SESSIONS # Import the AuthHandler class from auth import AuthHandler load_dotenv() # Configuration USERNAME = os.environ.get("CROSSFIT_USERNAME") PASSWORD = os.environ.get("CROSSFIT_PASSWORD") if not all([USERNAME, PASSWORD]): raise ValueError("Missing environment variables: CROSSFIT_USERNAME and/or CROSSFIT_PASSWORD") APPLICATION_ID = "81560887" CATEGORY_ID = "677" # Activity category ID for CrossFit TIMEZONE = "Europe/Paris" # Adjust to your timezone # Booking window configuration (can be overridden by environment variables) # TARGET_RESERVATION_TIME: string "HH:MM" local time when bookings open (default 20:01) # BOOKING_WINDOW_END_DELTA_MINUTES: int minutes after target time to stop booking (default 10) TARGET_RESERVATION_TIME = os.environ.get("TARGET_RESERVATION_TIME", "20:01") BOOKING_WINDOW_END_DELTA_MINUTES = int(os.environ.get("BOOKING_WINDOW_END_DELTA_MINUTES", "10")) DEVICE_TYPE = "3" # Retry configuration RETRY_MAX = 3 RETRY_BACKOFF = 1 APP_VERSION = "5.09.21" class Booker: """ A class for handling the main booking logic. This class is designed to be used as a standalone component that can be initialized with authentication and session management and used to perform the booking process. """ def __init__(self, auth_handler: AuthHandler, notifier: SessionNotifier) -> None: """ Initialize the Booker with necessary attributes. Args: auth_handler (AuthHandler): AuthHandler instance for authentication. notifier (SessionNotifier): SessionNotifier instance for sending notifications. """ self.auth_handler = auth_handler self.notifier = notifier # Initialize the session and headers self.session: requests.Session = requests.Session() self.base_headers: Dict[str, str] = { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0", "Content-Type": "application/x-www-form-urlencoded", "Nubapp-Origin": "user_apps", } self.session.headers.update(self.base_headers) # Define mandatory parameters for API calls self.mandatory_params: Dict[str, str] = { "app_version": APP_VERSION, "device_type": DEVICE_TYPE, "id_application": APPLICATION_ID, "id_category_activity": CATEGORY_ID } def get_auth_headers(self) -> Dict[str, str]: """ Return headers with authorization from the AuthHandler. Returns: Dict[str, str]: Headers dictionary with authorization if available. """ return self.auth_handler.get_auth_headers() async def booker(self, current_time: datetime) -> None: """ Run one cycle of checking and booking sessions. Args: current_time (datetime): Current time for comparison. """ # Calculate date range to check (current day, day + 1, and day + 2) start_date: date = current_time.date() end_date: date = start_date + timedelta(days=2) # Only go up to day + 2 # Get available sessions sessions_data: Optional[Dict[str, Any]] = self.get_available_sessions(start_date, end_date) if not sessions_data or not sessions_data.get("success", False): logging.error("No sessions available or error fetching sessions") return activities: List[Dict[str, Any]] = sessions_data.get("data", {}).get("activities_calendar", []) # Display all available sessions within the date range self.display_upcoming_sessions(activities, current_time) # Find sessions to book (preferred only) within allowed date range found_preferred_sessions: List[Dict[str, Any]] = [] for session in activities: session_time: datetime = parse(session["start_timestamp"]) if not session_time.tzinfo: session_time = pytz.timezone(TIMEZONE).localize(session_time) # Check if session is within allowed date range (current day, day + 1, or day + 2) days_diff = (session_time.date() - current_time.date()).days if not (0 <= days_diff <= 2): continue # Skip sessions outside the allowed date range # Check if session is preferred and bookable if self.is_session_bookable(session, current_time): if self.matches_preferred_session(session, current_time): found_preferred_sessions.append(session) # Display preferred sessions found if found_preferred_sessions: logging.info("Preferred sessions found:") for session in found_preferred_sessions: session_time: datetime = parse(session["start_timestamp"]) if not session_time.tzinfo: session_time = pytz.timezone(TIMEZONE).localize(session_time) logging.info(f"ID: {session['id_activity_calendar']}, Name: {session['name_activity']}, Time: {session_time.strftime('%Y-%m-%d %H:%M')}") else: logging.info("No matching preferred sessions found") # Book preferred sessions if not found_preferred_sessions: logging.info("No matching sessions found to book") return # Book sessions (preferred first) sessions_to_book = [("Preferred", session) for session in found_preferred_sessions] sessions_to_book.sort(key=lambda x: 0 if x[0] == "Preferred" else 1) booked_sessions = [] for session_type, session in sessions_to_book: session_time: datetime = parse(session["start_timestamp"]) logging.info(f"Attempting to book {session_type} session at {session_time} ({session['name_activity']})") if self.book_session(session["id_activity_calendar"]): # Display booked session booked_sessions.append(session) logging.info(f"Successfully booked {session_type} session at {session_time}") # Notify about booked session session_details = f"{session['name_activity']} at {session_time.strftime('%Y-%m-%d %H:%M')}" await self.notifier.notify_session_booking(session_details) else: logging.error(f"Failed to book {session_type} session at {session_time}") # Notify about failed booking session_details = f"{session['name_activity']} at {session_time.strftime('%Y-%m-%d %H:%M')}" await self.notifier.notify_impossible_booking(session_details) logging.info(f"Notified about impossible booking for {session_type} session at {session_time}") # Display all booked session(s) if booked_sessions: logging.info("Booked sessions:") for session in booked_sessions: session_time: datetime = parse(session["start_timestamp"]) if not session_time.tzinfo: session_time = pytz.timezone(TIMEZONE).localize(session_time) logging.info(f"ID: {session['id_activity_calendar']}, Name: {session['name_activity']}, Time: {session_time.strftime('%Y-%m-%d %H:%M')}") else: logging.info("No sessions were booked") async def run(self) -> None: """ Main execution loop. """ # Set up timezone tz: pytz.timezone = pytz.timezone(TIMEZONE) # Parse TARGET_RESERVATION_TIME to get the target hour and minute target_hour, target_minute = map(int, TARGET_RESERVATION_TIME.split(":")) target_time = datetime.now(tz).replace(hour=target_hour, minute=target_minute, second=0, microsecond=0) booking_window_end = target_time + timedelta(minutes=BOOKING_WINDOW_END_DELTA_MINUTES) # Initial login if not self.auth_handler.login(): logging.error("Authentication failed - exiting program") return try: while True: try: current_time: datetime = datetime.now(tz) logging.info(f"Current time: {current_time}") # Only book sessions if current time is within the booking window if target_time <= current_time <= booking_window_end: # Run booking cycle to check for preferred sessions and book await self.booker(current_time) # Wait for a short time before next check time.sleep(60) else: # Check again in 5 minutes if outside booking window time.sleep(300) except Exception as e: logging.error(f"Unexpected error in booking cycle: {str(e)} - Traceback: {traceback.format_exc()}") time.sleep(60) # Wait before retrying after error except KeyboardInterrupt: self.quit() def quit(self) -> None: """ Clean up resources and exit the script. """ logging.info("Script interrupted by user. Quitting...") exit(0) def get_available_sessions(self, start_date: date, end_date: date) -> Optional[Dict[str, Any]]: """ Fetch available sessions from the API. Args: start_date (date): Start date for fetching sessions. end_date (date): End date for fetching sessions. Returns: Optional[Dict[str, Any]]: Dictionary containing available sessions if successful, None otherwise. """ from session_manager import SessionManager session_manager = SessionManager(self.auth_handler) return session_manager.get_available_sessions(start_date, end_date) def book_session(self, session_id: str) -> bool: """ Book a specific session. Args: session_id (str): ID of the session to book. Returns: bool: True if booking is successful, False otherwise. """ from session_manager import SessionManager session_manager = SessionManager(self.auth_handler) return session_manager.book_session(session_id) def get_booked_sessions(self) -> List[Dict[str, Any]]: """ Get a list of booked sessions. Returns: A list of dictionaries containing information about the booked sessions. """ from session_manager import SessionManager session_manager = SessionManager(self.auth_handler) return session_manager.get_booked_sessions() def is_session_bookable(self, session: Dict[str, Any], current_time: datetime) -> bool: """ Check if a session is bookable based on user_info. Args: session (Dict[str, Any]): Session data. current_time (datetime): Current time for comparison. Returns: bool: True if the session is bookable, False otherwise. """ from session_manager import SessionManager session_manager = SessionManager(self.auth_handler) return session_manager.is_session_bookable(session, current_time) def matches_preferred_session(self, session: Dict[str, Any], current_time: datetime) -> bool: """ Check if session matches one of your preferred sessions with exact matching. Args: session (Dict[str, Any]): Session data. current_time (datetime): Current time for comparison. Returns: bool: True if the session matches a preferred session, False otherwise. """ from session_manager import SessionManager session_manager = SessionManager(self.auth_handler) return session_manager.matches_preferred_session(session, current_time) def display_upcoming_sessions(self, sessions: List[Dict[str, Any]], current_time: datetime) -> None: """ Display upcoming sessions with ID, name, date, and time. Args: sessions (List[Dict[str, Any]]): List of session data. current_time (datetime): Current time for comparison. """ from session_manager import SessionManager session_manager = SessionManager(self.auth_handler) session_manager.display_upcoming_sessions(sessions, current_time)