@ -172,10 +172,49 @@ def load_config(config_path: str = "config.json") -> Config:
if " Name " not in beh or " DaysInAdvance " not in beh or " AttemptSchedule " not in beh :
if " Name " not in beh or " DaysInAdvance " not in beh or " AttemptSchedule " not in beh :
raise ValueError ( f " Invalid reservation behavior: { beh } " )
raise ValueError ( f " Invalid reservation behavior: { beh } " )
if not beh [ " AttemptSchedule " ] :
raise ValueError ( f " ReservationBehavior ' { beh [ ' Name ' ] } ' must have at least one AttemptSchedule entry " )
schedules = [ ]
schedules = [ ]
for sched in beh [ " AttemptSchedule " ] :
prev_time_minutes = - 1
for i , sched in enumerate ( beh [ " AttemptSchedule " ] ) :
if " Time " not in sched or " Attempts " not in sched or " DelaySeconds " not in sched :
if " Time " not in sched or " Attempts " not in sched or " DelaySeconds " not in sched :
raise ValueError ( f " Invalid attempt schedule: { sched } " )
raise ValueError ( f " Invalid attempt schedule in ' { beh [ ' Name ' ] } ' : { sched } " )
# Validate time format and check ascending order
time_str = sched [ " Time " ]
try :
# Parse time "HH:MM:SS-TZ:00" format
if len ( time_str ) < 8 :
raise ValueError ( f " Invalid time format " )
time_part = time_str [ : 8 ]
parts = time_part . split ( ' : ' )
if len ( parts ) != 3 :
raise ValueError ( f " Invalid time format " )
hour = int ( parts [ 0 ] )
minute = int ( parts [ 1 ] )
second = int ( parts [ 2 ] )
if not ( 0 < = hour < = 23 and 0 < = minute < = 59 and 0 < = second < = 59 ) :
raise ValueError ( f " Invalid time values " )
# Convert to minutes for comparison
current_time_minutes = hour * 60 + minute
if current_time_minutes < = prev_time_minutes :
raise ValueError (
f " AttemptSchedule times in ' { beh [ ' Name ' ] } ' must be in ascending order. "
f " Entry { i + 1 } time ' { time_str } ' is not after previous entry. "
f " Please sort AttemptSchedule entries by time. "
)
prev_time_minutes = current_time_minutes
except ValueError as e :
if " ascending order " in str ( e ) or " AttemptSchedule " in str ( e ) :
raise
raise ValueError ( f " Invalid time format ' { time_str } ' in ' { beh [ ' Name ' ] } ' : { e } " )
schedules . append ( AttemptSchedule (
schedules . append ( AttemptSchedule (
time = sched [ " Time " ] ,
time = sched [ " Time " ] ,
attempts = sched [ " Attempts " ] ,
attempts = sched [ " Attempts " ] ,
@ -681,21 +720,85 @@ class HarborBot:
print ( " [ERROR] Failed to authenticate. Exiting. " )
print ( " [ERROR] Failed to authenticate. Exiting. " )
return
return
# Print initial status showing next scheduled attempts
self . _print_upcoming_reservations ( )
last_status_print = datetime . now ( )
while True :
while True :
try :
try :
self . _daemon_iteration ( dry_run )
self . _daemon_iteration ( dry_run )
# Sleep until next check (check every minute)
# Print status every 30 minutes
time . sleep ( 60 )
if ( datetime . now ( ) - last_status_print ) . total_seconds ( ) > 1800 :
self . _print_upcoming_reservations ( )
last_status_print = datetime . now ( )
# Sleep until next check (check every 30 seconds for better timing accuracy)
time . sleep ( 30 )
except KeyboardInterrupt :
except KeyboardInterrupt :
print ( " \n [INFO] Daemon stopped by user " )
print ( " \n [INFO] Daemon stopped by user " )
break
break
except Exception as e :
except Exception as e :
print ( f " [ERROR] Daemon error: { e } " )
print ( f " [ERROR] Daemon error: { e } " )
import traceback
traceback . print_exc ( )
print ( " [INFO] Sleeping 60s before retry... " )
print ( " [INFO] Sleeping 60s before retry... " )
time . sleep ( 60 )
time . sleep ( 60 )
def _print_upcoming_reservations ( self ) :
""" Print status of upcoming reservations. """
print ( f " \n [STATUS] { datetime . now ( ) . strftime ( ' % Y- % m- %d % H: % M: % S ' ) } - Checking upcoming reservations: " )
upcoming = [ ]
for reservation in self . config . reservations :
behavior = self . config . reservation_behaviors [ reservation . reservation_behavior ]
target_date = self . calculate_reservation_date ( reservation . day , behavior . days_in_advance )
if not target_date :
continue
# Get timezone from reservation time
_ , _ , _ , tz_offset = self . _parse_schedule_time ( reservation . time )
now = self . _get_current_time_in_tz ( tz_offset )
today = now . date ( )
# Find next upcoming attempt window
next_attempt_time = None
for schedule in behavior . attempt_schedule :
sched_time = self . _get_schedule_datetime ( schedule . time , datetime . combine ( today , datetime . min . time ( ) ) )
if sched_time > now :
next_attempt_time = sched_time
break
if next_attempt_time :
wait_seconds = ( next_attempt_time - now ) . total_seconds ( )
upcoming . append ( {
' reservation ' : reservation ,
' target_date ' : target_date ,
' next_attempt ' : next_attempt_time ,
' wait_seconds ' : wait_seconds
} )
if not upcoming :
print ( " No reservations scheduled for today " )
else :
# Sort by next attempt time
upcoming . sort ( key = lambda x : x [ ' next_attempt ' ] )
for item in upcoming :
res = item [ ' reservation ' ]
wait = item [ ' wait_seconds ' ]
if wait < 60 :
wait_str = f " { wait : .0f } s "
elif wait < 3600 :
wait_str = f " { wait / 60 : .1f } m "
else :
wait_str = f " { wait / 3600 : .1f } h "
print ( f " - { res . day } @ { res . time } -> { item [ ' target_date ' ] . strftime ( ' % Y- % m- %d ' ) } : "
f " Next attempt at { item [ ' next_attempt ' ] . strftime ( ' % H: % M: % S ' ) } (in { wait_str } ) " )
def _daemon_iteration ( self , dry_run : bool ) :
def _daemon_iteration ( self , dry_run : bool ) :
""" Single iteration of the daemon loop. """
""" Single iteration of the daemon loop. """
# Re-check authentication periodically
# Re-check authentication periodically
@ -720,31 +823,64 @@ class HarborBot:
now = self . _get_current_time_in_tz ( tz_offset )
now = self . _get_current_time_in_tz ( tz_offset )
today = now . date ( )
today = now . date ( )
# Get first and last attempt times
# Find if we're within any attempt window (within 60 seconds of a scheduled time)
first_schedule = behavior . attempt_schedule [ 0 ]
for schedule in behavior . attempt_schedule :
last_schedule = behavior . attempt_schedule [ - 1 ]
sched_time = self . _get_schedule_datetime ( schedule . time , datetime . combine ( today , datetime . min . time ( ) ) )
time_diff = ( now - sched_time ) . total_seconds ( )
# Trigger if we're within 0-60 seconds after the scheduled time
if 0 < = time_diff < = 60 :
print ( f " \n [TRIGGER] { reservation . day } @ { reservation . time } for { target_date . strftime ( ' % Y- % m- %d ' ) } " )
print ( f " [INFO] Attempt window: { schedule . time } (triggered at { now . strftime ( ' % H: % M: % S ' ) } ) " )
self . _execute_single_attempt_window ( reservation , schedule , target_date , dry_run )
break # Only execute once per iteration
def _execute_single_attempt_window (
self ,
reservation : Reservation ,
schedule : AttemptSchedule ,
target_date : datetime ,
dry_run : bool
) :
""" Execute attempts for a single attempt window. """
reservation_datetime = target_date . strftime ( f " %Y-%m-%dT { reservation . time [ : 8 ] } " ) + reservation . time [ 8 : ]
reservation_date_str = target_date . strftime ( " % Y- % m- %d " )
first_time = self . _get_schedule_datetime ( first_schedule . time , datetime . combine ( today , datetime . min . time ( ) ) )
print ( f " [INFO] Booking for: { reservation_datetime } " )
last_time = self . _get_schedule_datetime ( last_schedule . time , datetime . combine ( today , datetime . min . time ( ) ) )
print ( f " [INFO] Attempts: { schedule . attempts } , Delay: { schedule . delay_seconds } s " )
# Add buffer for last attempt window (assume max 10 minutes for attempts)
for court_res in reservation . courts :
last_time_with_buffer = last_time + timedelta ( minutes = 10 )
court = self . config . courts [ court_res . court ]
account = self . config . accounts [ court_res . account ]
# Check if we're before the first window (wait) or past the last window (skip)
# Check if already successful
if now < first_time - timedelta ( minutes = 5 ) :
if self . _is_reservation_successful ( reservation_date_str , court_res . court , court_res . account ) :
# More than 5 minutes before first attempt - not time yet
print ( f " [SKIP] { court . name } for { account . name } - already booked successfully " )
wait_seconds = ( first_time - now ) . total_seconds ( )
if wait_seconds < 300 : # Less than 5 minutes away
print ( f " \n [INFO] { reservation . day } @ { reservation . time } : Starting in { wait_seconds : .0f } s " )
continue
continue
if now > last_time_with_buffer :
for attempt in range ( schedule . attempts ) :
# Past all attempt windows for today
if dry_run :
continue
print ( f " [DRY RUN] Attempt { attempt + 1 } / { schedule . attempts } : "
f " Would book { court . name } for { account . name } " )
else :
print ( f " [ATTEMPT] { attempt + 1 } / { schedule . attempts } : "
f " Booking { court . name } for { account . name } ... " )
result = self . book_appointment ( court , account , reservation_datetime )
if result . get ( " Success " ) :
print ( f " [SUCCESS] Booked { court . name } for { account . name } " )
self . _mark_reservation_successful ( reservation_date_str , court_res . court , court_res . account )
break
else :
print ( f " [FAILED] { result . get ( ' Message ' , ' Unknown error ' ) } " )
if result . get ( " AllowRetry " ) is False and " no longer available " in result . get ( " Message " , " " ) :
print ( f " [INFO] Slot no longer available, moving to next court " )
break
# We're within the attempt window - execute
if attempt < schedule . attempts - 1 and schedule . delay_seconds > 0 :
print ( f " \n [TRIGGER] { reservation . day } @ { reservation . time } for { target_date . strftime ( ' % Y- % m- %d ' ) } " )
if not dry_run :
self . execute_reservation_attempts ( reservation , behavior , target_date , dry_run )
time . sleep ( schedule . delay_seconds )
# After executing, we'll skip this reservation until tomorrow
# After executing, we'll skip this reservation until tomorrow
# (the success tracking prevents re-booking, and time checks prevent re-triggering)
# (the success tracking prevents re-booking, and time checks prevent re-triggering)