Skip to content

Instantly share code, notes, and snippets.

@hhe
Last active February 26, 2025 22:33
Show Gist options
  • Save hhe/c97916f0f346a3ed45a5ca06f97f527a to your computer and use it in GitHub Desktop.
Save hhe/c97916f0f346a3ed45a5ca06f97f527a to your computer and use it in GitHub Desktop.
#!/usr/bin/env python3
'''
INSTRUCTIONS:
1. Install Python 3
2. In the terminal, run "python PYcklebot.py -h" to see example usage.
Note: you may end up with multiple successful bookings; if this happens just cancel the extras.
'''
import argparse, datetime, re, requests, time, html
from http.cookiejar import MozillaCookieJar
from multiprocessing.dummy import Pool
parser = argparse.ArgumentParser()
parser.add_argument('-u', '--username', default='johndoe')
parser.add_argument('-p', '--password', default='p4ssw0rd')
parser.add_argument('-d', '--date', default=None, help='e.g. "2022-11-17", or omit for default of 8 days ahead')
parser.add_argument('-t', '--time', default='8-930', help='e.g. "730-9", court time in colloquial format')
parser.add_argument('-i', '--interval', default=0.7, type=float, help='Polling interval in seconds, decrease to be more aggressive. Default 0.5')
parser.add_argument('-b', '--ball-machine', action='store_true')
parser.add_argument('-T', '--tennis', action='store_true', help='Instead of pickleball')
parser.add_argument('-c', '--court', default='any court', help='e.g. "14a"')
parser.add_argument('-s', '--show-only', action='store_true', help='List existing reservations only')
args = parser.parse_args()
def parseslot(text):
am = 'a' in text.lower()
s = re.sub(r'\D','', text)
def to_time(x, am=False):
h = int('0' + x[:-2])
if h > 0 and h < 10 and not am:
h += 12
return h, int(x[-2:])
def to_elapsed(x, am=False):
h, m = to_time(x, am)
return 60*h + m
if int(s[-2:]) % 30 > 0:
s += '00'
l, h = (1, len(s) - 3) if s[-2:] == '00' else (1, len(s) - 2) if s[-2:] == '30' else (len(s) - 2, len(s) - 2)
for d in range(l, h + 1):
a, b = s[:d], s[d:]
if b[0] == '0':
continue
if int(a[-2:]) % 30 > 0:
a += '00'
dur = to_elapsed(b, am)
if dur in (30, 60, 90) and to_time(a, am)[0] < 24:
break
dur -= to_elapsed(a, am)
if dur in (30, 60, 90):
break
h, m = to_time(a, am)
if h >= 24 or m >= 60:
raise ValueError("Could not parse time slot '%s'" % text)
return h, m, dur
def parsecourt(text):
if text.isnumeric():
text = int(text)
return {
# TODO: fill in tennis court IDs
15: '51368',
}.get(text, text)
return {
'a': '47525', 'b': '47526', 'c': '51366', 'd': '51367',
}.get(text[-1:].lower(), '')
def ensure_login(s):
s.cookies = MozillaCookieJar('cookie-%s.txt' % args.username)
try:
s.cookies.load(ignore_discard=True)
except:
pass
while True:
r = s.get('https://app.cour\164reserve.com/Online/Account/LogIn/12465')
m = re.search(r'name="__RequestVerificationToken" type="hidden" value="([-\w]+)"', r.text)
if m is None:
m = re.search(r'<li class="fn-ace-parent-li float-right"\s+id=my-account-li-web\s+>\s*<a href="#"\s+class="parent-header-link">\s*<span>\s*([^<]+)\s*</span>\s*</a>', r.text)
print("Logged in as %s!" % m.group(1))
s.cookies.save(ignore_discard=True)
break
if args.username != parser.get_default('username'):
print("Logging in as %s" % args.username)
r = s.post('https://app.cour\164reserve.com/Online/Account/LogIn/12465', {
'UserNameOrEmail': args.username,
'Password': args.password,
'RememberMe': 'true',
'__RequestVerificationToken': m.group(1),
}, headers={
# 'x-requested-with': 'XMLHttpRequest'
})
if 'Incorrect' not in r.text:
continue
print("Could not log in as %s!" % args.username)
args.username = input("Enter username: ")
args.password = input("Enter password: ")
def get_session_info(s):
local = datetime.datetime.now(datetime.timezone.utc)
r = s.get('https://app.cour\164reserve.com/Online/Bookings/List/12465', params={'type': 1})
assert r.headers['Date'][-3:] == 'GMT'
remote = datetime.datetime.strptime(r.headers['Date'][:-3] + '+0000', '%a, %d %b %Y %H:%M:%S %z')
member_id = re.search('&memberId=(\d+)', r.text).group(1)
url = html.unescape(re.search(r"fixUrl\('(https://reservations.cour\164reserve.com/Online/BookingsApi/ApiList.+?)'\)", r.text).group(1))
request_data = requests.utils.unquote(re.search(r'requestData=(.*)$', url).group(1))
def get_court_num(res_id):
r = s.get(f'https://app.courtreserve.com/Online/MyProfile/UpdateMyReservation/12465?reservationId={res_id}')
return (
re.search(r'startTimeAttrs="" type="text" value="([^"]+)"', r.text).group(1),
re.search(r'name="Duration" style="width:100%" type="text" value="(\d+)"', r.text).group(1),
re.search(r'Pickleball - ([^<]+)</label>', r.text).group(1).replace('Pickleball / Mini Tennis', '').strip()
)
r = s.post('https://api4.cour\164reserve.com/Online/BookingsApi/ApiLo\141dBookings', {'BookingTypesString': '1'}, params={'id': '12465', 'requestData': request_data})
existing_courts = {
datetime.datetime.strptime(
re.sub(r'(\d+)(st|nd|rd|th)', r'\1', x),
'%a, %b %d %Y' if re.search(r'\d{4}', x) else '%a, %b %d'
).replace(**({'year': remote.year} if not re.search(r'\d{4}', x) else {})).strftime('%m/%d/%Y') : get_court_num(res_id)
for res_id, x in re.findall(r'Online/MyProfile/Reservation/12465/(\d+)\\"[^$]*(\w{3}, \w{3} \d{1,2}\w{2}(?: \d{4})?),', r.text)
}
return member_id, request_data, remote - local, existing_courts
def to_string(time):
return time.strftime('%Y-%m-%d %H:%M %Z')
def server_time(tz=None): # Get the server time in the court's timezone
return datetime.datetime.now(tz) + time_offset
try:
import dateutil.tz
tz = dateutil.tz.gettz('US/Pacific') # The tennis center is in Pacific Time Zone
except:
print("dateutil missing, defaulting to system local time zone. This is okay if your system is in Pacific Time. Install dateutil module to avoid this issue")
tz = None
if args.date == None:
date = datetime.datetime.now() + datetime.timedelta(days=8) # 8 days after current date
else:
date = datetime.datetime.strptime(args.date, '%Y-%m-%d')
date_mdy = date.strftime('%m/%d/%Y')
y, m, d, *_ = date.timetuple()
hh, mm, duration = parseslot(args.time)
playtime = datetime.datetime(y, m, d, hh, mm, tzinfo=tz)
time_hms = playtime.strftime('%H:%M:%S')
if args.tennis:
raise NotImplementedError("TODO: implement tennis")
s = requests.session()
s.headers.update({
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.129 Safari/537.36',
})
ensure_login(s)
member_id, request_data, time_offset, existing_courts = get_session_info(s)
if args.show_only:
print(existing_courts)
exit(0)
if date_mdy in existing_courts:
exit(input("You already have a booking on %s, so there's nothing I can do!" % date.strftime('%Y-%m-%d')))
# print('Server is at most %f seconds ahead' % time_offset.total_seconds())
print('Target: %s (%s) for %d minutes' % (to_string(playtime), args.court, duration))
droptime = datetime.datetime(y, m, d, 12, 30, tzinfo=tz) - datetime.timedelta(days=8)
# Correct for Python's wall-clock shenanigans around DST https://stackoverflow.com/a/63581287
from_droptime = lambda st: st.astimezone(datetime.timezone.utc) - droptime.astimezone(datetime.timezone.utc)
while True:
wait = datetime.timedelta(seconds=-2) - from_droptime(server_time(tz)) # start at T-minus 2s
waitsec = wait.total_seconds()
if waitsec < 0:
break
print('You can start booking at %s. Waiting %s' % (to_string(droptime), wait))
time.sleep(min(waitsec, max(waitsec/2, 2)))
# Apparently only the final POST request is necessary.
# Do we check what's available, or be so fast we don't need to check? I think the latter folks.
finished = False
def cb(time_sent, r):
global finished, request_data
print(' <---- ' + time_sent + ' ', end = '')
if 'Reservation Confirmed' in r.text:
print('succeeded: %s (%s) for %d minutes' % (to_string(playtime), args.court, duration))
finished = True
elif 'restricted to 1 court' in r.text:
print('only one reservation allowed per day')
finished = True
elif finished:
print('')
elif 'is only allowed to reserve up to' in r.text:
print('nothing available yet')
elif 'Sorry, no available courts' in r.text:
print('no longer available')
elif 'Something wrong, please try again' in r.text:
print('Something wrong, please try again')
print(r.text)
# May need to refresh request_data when this happens
_, request_data, _, _ = get_session_info(s)
elif '<span class="code-label">Error code' in r.text:
match = re.search(r' \| (\d+: [^<]+)</title>', r.text)
print('Cloudflare ' + (match.group(1) if match else '???'))
else:
print('unknown (server overloaded?)')
open('unknown_%s_%s.htm' % (member_id, time_sent.replace(':', '_')), 'w').write(r.text)
pool = Pool(10)
while not finished:
if playtime + datetime.timedelta(minutes=duration) < server_time(tz):
exit(input("Cannot book for time in the past!"))
time_sent = server_time(tz).strftime("%d %H:%M:%S.%f")[:-4]
print('\n' + time_sent + ' (' + request_data[-10:] + ')')
result = pool.apply_async(s.post, ['https://api4.cour\164reserve.com/Online/Res\145rvationsApi/CreateRes\145rvation/12465', {
'Id': '12465',
'OrgId': '12465',
'MemberId': member_id,
'MemberIds': '',
'IsConsolidatedScheduler': 'True',
'HoldTimeForReservation': '15',
'RequirePaymentWhenBookingCourtsOnline': 'False',
'AllowMemberToPickOtherMembersToPlayWith': 'False',
'ReservableEntityName': 'Court',
'IsAllowedToPickStartAndEndTime': 'False',
'CustomSchedulerId': '16834',
'IsConsolidated': 'True',
'IsToday': 'False',
'IsFromDynamicSlots': 'False',
'InstructorId': '',
'InstructorName': '',
'CanSelectCourt': 'False',
'IsCourtRequired': 'False',
'CostTypeAllowOpenMatches': 'False',
'IsMultipleCourtRequired': 'False',
'ReservationQueueId': '',
'ReservationQueueSlotId': '',
'RequestData': request_data,
'Id': '12465',
'OrgId': '12465',
'Date': date_mdy,
'SelectedCourtType': 'Pickleball',
'SelectedCourtTypeId': '9',
'SelectedResourceId': '',
'DisclosureText': '',
'DisclosureName': 'Court Reservations',
'IsResourceReservation': 'False',
'StartTime': time_hms,
'CourtTypeEnum': '9',
'MembershipId': '139864',
'CustomSchedulerId': '16834',
'IsAllowedToPickStartAndEndTime': 'False',
'UseMinTimeByDefault': 'False',
'IsEligibleForPreauthorization': 'False',
'MatchMakerSelectedRatingIdsString': '',
'DurationType': '',
'MaxAllowedCourtsPerReservation': '1',
'SelectedResourceName': '',
'ReservationTypeId': '68963',
'Duration': duration,
'CourtId': parsecourt(args.court),
'OwnersDropdown_input': '',
'OwnersDropdown': '',
'SelectedMembers[0].MemberId': member_id,
'DisclosureAgree': 'true',
}], {}, (lambda t: lambda r: cb(t, r))(time_sent[9:]), error_callback=print) # https://stackoverflow.com/a/2295368
time.sleep(args.interval if from_droptime(server_time(tz)).total_seconds() < 20 else 120)
result.wait(0.1)
pool.close()
pool.join()
input("Press Enter to quit")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment