Last active
February 26, 2025 22:33
-
-
Save hhe/c97916f0f346a3ed45a5ca06f97f527a to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
requests |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment