Skip to content

Instantly share code, notes, and snippets.

@hhe
Last active March 2, 2024 02:32
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • 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
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")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment