Last active
July 17, 2025 21:45
-
-
Save hhe/c97916f0f346a3ed45a5ca06f97f527a to your computer and use it in GitHub Desktop.
This file contains hidden or 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 | |
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) |
This file contains hidden or 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.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") |
This file contains hidden or 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 | |
pycryptodome |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment