Skip to content

Instantly share code, notes, and snippets.

@hhe
Last active July 17, 2025 21:45
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
from flask import Flask, Response
from flask_socketio import SocketIO
import subprocess
app = Flask(__name__)
socketio = SocketIO(app)
HTML_PAGE = """
<!DOCTYPE html>
<html>
<head>
<title>Check Courts</title>
<script src="https://cdn.socket.io/4.0.0/socket.io.min.js"></script>
</head>
<body>
<div style="font-family:sans-serif">
<h2>Check Courts</h2>
<button onclick="runScript()">rm -rf /</button>
<pre id="output" style="background:#eee; padding:1em;"></pre>
<br />Note: it does not show the court number for same day bookings. I haven't figured this part out yet.
<h2>Login Assistant</h2>
Under maintenance, check back soon.
</div>
<script>
const socket = io();
const output = document.getElementById("output");
let raw = "";
function runScript() {
raw = "";
output.textContent = "";
const btn = document.querySelector("button");
btn.disabled = true;
socket.emit("run_script");
setTimeout(() => {
btn.disabled = false;
}, 15000);
}
socket.on("output", function(data) {
raw += data;
output.textContent = raw;
if (data.includes("--- done ---")) {
formatOutput(raw);
}
});
function formatOutput(text) {
const lines = text.split(/[\\r\\n]+/).filter(Boolean);
const results = {};
let currentName = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
//console.log(line)
const m = line.match(/^Logged in as (.+?)!$/);
if (m) {
currentName = m[1];
//console.log(currentName)
} else if (currentName) {
try {
const bookings = JSON.parse(line.replace(/'/g, '"').replace(/\\(/g, '[').replace(/\\)/g, ']'));
//console.log(bookings)
for (const date in bookings) {
const [time, mins, court] = bookings[date];
if (!results[date]) results[date] = [];
results[date].push(`${time} (${mins} min) — ${court} — ${currentName}`);
}
} catch (e) {
// Ignore invalid lines
}
}
}
const sortedDates = Object.keys(results).sort();
let final = "";
for (const date of sortedDates) {
final += `📅 ${date}\\n`;
results[date].sort();
results[date].forEach(entry => final += ` • ${entry}\\n`);
final += "\\n";
}
output.textContent = final || "No valid court data found.";
}
</script>
</body>
</html>
"""
@app.route("/check_courts")
def check_courts():
return Response(HTML_PAGE, mimetype="text/html")
@socketio.on("run_script")
def run_script():
print("Running script")
process = subprocess.Popen(
["stdbuf", "-oL", "bash", "-c", "/home/ubuntu/picklebot/_show.sh"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1
)
for line in iter(process.stdout.readline, ''):
print(line, end='')
socketio.emit("output", line)
process.stdout.close()
process.wait()
socketio.emit("output", "--- done ---")
if __name__ == "__main__":
import eventlet
import eventlet.wsgi
eventlet.monkey_patch()
socketio.run(app, host="0.0.0.0", port=80)
#!/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.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('-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)
request_data = requests.utils.unquote(re.search(r"var requestData = '(.*)';", r.text).group(1))
def get_court_num(res_id, datestr):
try:
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()
)
except:
return datestr
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, datestr)
for res_id, x, datestr in re.findall(r'Online/MyProfile/Reservation/12465/(\d+)\\"[^,$]*(\w{3}, \w{3} \d{1,2}\w{2}(?: \d{4})?), *([^\\r\\n]+)', 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
k = 1e-3*max(0, from_droptime(server_time(tz)).total_seconds())
time.sleep((args.interval + 120*k)/(1 + k))
result.wait(0.1)
pool.close()
pool.join()
input("Press Enter to quit")
requests
pycryptodome
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment