Last active
March 2, 2024 02:32
-
-
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 | |
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.5, 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('-n', '--dry-run', action='store_true', help='Select but do not confirm the booking, for testing') | |
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 < 11 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 { | |
1: 45, 2: 46, 3: 47, 4: 48, 5: 49, 6: 50, 7: 51, 8: 162, 9: 163, | |
10: 164, 11: 166, 13: 185, 16: 186, 17: 187, 18: 188, 15: 193, | |
}.get(text, text) | |
else: | |
return { | |
'a': 189, 'b': 190, 'c': 191, 'd': 192, | |
}.get(text[-1:].lower(), -1) | |
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://g\164c.club\141utomation.com') | |
m = re.search(r'name="login_token" value="(\w+)"', r.text) | |
if m is None: | |
m = re.search(r'<p class="big">([^!]+)!</p>', 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://g\164c.club\141utomation.com/login/login', { | |
'email': args.username, | |
'password': args.password, | |
'login_token': 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://g\164c.club\141utomation.com/ev\145nt/res\145rve-court-n\145w') | |
assert r.headers['Date'][-3:] == 'GMT' | |
remote = datetime.datetime.strptime(r.headers['Date'][:-3] + '+0000', '%a, %d %b %Y %H:%M:%S %z') | |
user_id = re.search('name="user_id" value="(\d+)"', r.text).group(1) | |
res_token = re.search('name="event_memb\145r_token_res\145rve_court" value="(\w+)"', r.text).group(1) | |
existing_courts = [datetime.datetime.strptime(x, '%a, %b %d, %Y').strftime('%m/%d/%Y') | |
for x in re.findall('<b>\s+(\w{3}, \w{3} \d{1,2}, \d{4})\s+</b>', r.text)] | |
return user_id, res_token, 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) | |
location = 1 if args.tennis else 35 | |
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) | |
user_id, res_token, time_offset, existing_courts = get_session_info(s) | |
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=-1) - from_droptime(server_time(tz)) # start at T-minus 1s | |
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 (with 'is_confirmed' == 1) 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 | |
print(' <-- ' + time_sent + ' ', end = '') | |
if 'completed' in r.text: | |
print('succeeded: %s (%s) for %d minutes' % (to_string(playtime), args.court, duration)) | |
finished = True | |
elif 'will be invoiced' in r.text: | |
print('will be invoiced') | |
finished = True | |
elif finished: | |
print('') | |
elif 'Unable to find' in r.text: | |
print('nothing available yet') | |
elif 'no longer available' in r.text: | |
print('no longer available') | |
elif 'One reservation per day' in r.text: | |
print('only one reservation allowed per day') | |
elif 'system error' in r.text: | |
print('system error (server overloaded?)') | |
else: | |
print('unknown (server overloaded?)') | |
open('unknown_%s_%s.htm' % (user_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("%H:%M:%S.%f")[:-4] | |
print('\n' + time_sent) | |
result = pool.apply_async(s.post, ['https://g\164c.club\141utomation.com/ev\145nt/res\145rve-court-n\145w-do?aj\141x=true', { | |
'user_id': user_id, | |
'event_memb\145r_token_res\145rve_court': res_token, | |
'component': 2, | |
'location': location, | |
'court': parsecourt(args.court), | |
'host': user_id, | |
'ball_m\141chine': int(args.ball_machine), | |
'date': date_mdy, | |
'interval': duration, | |
'time-res\145rve': int(playtime.timestamp()), | |
'location-res\145rve': location, | |
'is_confirmed': 0 if args.dry_run else 1, | |
}], {'headers': { | |
'origin': 'https://g\164c.club\141utomation.com', | |
'referer': 'https://g\164c.club\141utomation.com/ev\145nt/res\145rve-court-n\145w', | |
}}, (lambda t: lambda r: cb(t, r))(time_sent[6:])) # https://stackoverflow.com/a/2295368 | |
time.sleep(args.interval if from_droptime(server_time(tz)).total_seconds() < 10 else 180) | |
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