The Problem With Popular Courts
A popular public tennis center near me opens its booking window at exactly 7:00 AM for the following day only. No advance reservations. There are only 3 hard courts at this tennis center, you either hit the site at 7 AM or you likely won't be able to compete to get a booking for the most in-demand time-slots. I also like the idea of trying to book a slot last minute while I'm away from the computer and not having to go through the 10 pages of the reservation flow.
When I invite friends or hitting partners, it is an invitation that is met with excitement because of how beautiful the tennis center is. The excitement is amplified when a super convenient time of Saturday at 10AM is proposed. That's a time where you don't have to wake up stupid early and the sun is not so scorching hot.
I built a bot to make it easier for me to make an irresistible offer to my tennis hitting partners.
Architecture Overview
Three pieces:
1. tennis_automation.py — the Selenium script that does the actual booking. Logs into the court reservation system, searches for available slots, selects one, handles member selection, fills out the questions page, processes payment, solves the CAPTCHA, and submits.
2. tennis_bot.py — a Telegram bot that runs persistently. You message it a time the night before ("book tomorrow at 10am"), it queues the booking, and at 7 AM the cron fires and executes it. When the booking succeeds or fails, the bot messages you back.
3. Cron — two entries: one at 6:58 AM to prevent macOS sleep (caffeinate), one at 7:00 AM to run the booking script.
The booking script runs headlessly on a scheduler. The Telegram bot is the interface — it's how you tell the system what you want without touching a config file.
The Booking Flow, Step by Step
The court reservation platform (MyVSCloud/WebTrac) has a specific sequence that has to happen in order. This took most of the debugging time to get right.
Login — Navigate to the platform's login URL directly. If an "Active Session Alert" appears (from a prior session), click "Continue with Login" via WebDriverWait(10). The platform doesn't tolerate skipping this.
Court search — The date picker is a custom widget, not a plain <input>. You have to set the value via execute_script and then fire both input and change events manually. A normal element.send_keys() doesn't trigger the calendar logic.
Slot selection — Find <a> elements with class cart-button--state-block containing "am" or "pm" text. Clicking a slot opens an overlay. The bot waits for div.ui-widget-overlay to disappear before trying to click "Add to Cart" — the overlay blocks clicks and you'll get a silent failure otherwise.
Member selection — Select your name from a native <select> dropdown. The usage code ("Social") is a custom combobox: click the button, then click <li title="Social"> in the dropdown that appears.
Questions page — Singles/Doubles is another custom combobox (question187882_vm_1_button). Opens it and clicks <li data-value="Singles">. Guest name is a text field found by contains(@id,"guest").
Payment — All card fields are hidden inputs populated by JS using their IDs (webcheckout_nameoncard, webcheckout_cvv, etc.). The visible card number/expiry/CVV fields are three unnamed type="text" inputs in a row — filled by position [0], [1], [2].
Each page transition takes 5–8 seconds. Every wait uses WebDriverWait with 10–15 second timeouts. If you use time.sleep() for fixed delays, you'll either wait too long or get intermittent failures.
The CAPTCHA Problem
The payment page has a reCAPTCHA. On this site it always escalates to an image challenge — checking the box is not enough.
Two-layer approach:
Layer 1: undetected-chromedriver — This is a drop-in replacement for selenium.webdriver.Chrome that masks Selenium's automation fingerprints from reCAPTCHA's detection. It's what I use for any scraper on this machine. It prevents reCAPTCHA from detecting Selenium outright, which sometimes eliminates the image challenge entirely.
Layer 2: 2captcha — When the image challenge still appears (it does, roughly 30% of the time), the script submits the reCAPTCHA sitekey and page URL to the 2captcha API, polls for a solution token, injects it into g-recaptcha-response, and fires the callback. Each solve costs about $0.003 and takes 10–30 seconds. The bot has a 150-second timeout on CAPTCHA resolution before it gives up and sends you a failure alert.
The pre-7 AM checklist includes checking your 2captcha balance. Zero balance = failed booking at the last step.
Plan A / Plan B / Plan C
The slot selection logic has three tiers:
Plan A — Your requested time is available on a tennis court (the bot filters out pickleball and other court types). Books it automatically, sends Telegram confirmation.
Plan B — Your exact time is gone, but a tennis slot exists within ±1 hour. Books it, sends a Telegram alert with the actual time and the delta from what you asked for.
Plan C — Nothing within an hour, or you ran with --pick. The bot sends you a numbered list of all available tennis slots via Telegram and exits. You reply with a number at any point during the day — the booking window is open until 11:59 PM. The bot receives your reply, queues the chosen slot, and triggers the booking immediately.
This means even on a bad day where your preferred time is gone, you can still book something useful without touching the config file.
The Telegram Interface
tennis_bot.py runs as a persistent background process (not cron — it needs to be listening to receive replies). It handles a set of commands:
book 10am— queue a booking for tomorrow at 10 AMbook friday at 10am— queue for a specific day/queue— show pending bookings/cancel— remove the next pending booking1or2— pick reply for Plan C slot selectiontest 1— same as pick reply but runs in--testmode (doesn't submit)
The cron at 7 AM fires tennis_automation.py. That script checks the queue in tennis_config.json, processes any pending bookings, and the bot sends you the result.
What Actually Works
The full flow has been tested end-to-end in --demo mode (stops before the final submit button, sends a preflight screenshot via Telegram). Every step — login, court search, slot selection, overlay wait, cart, member selection, questions, checkout, payment fields, CAPTCHA solve — is confirmed working.
The --test flag runs without saving to the execution log, so you can test repeatedly without creating duplicate log entries. The --force flag bypasses idempotency checks if you need to rerun a failed booking.
Courts book in 1-hour blocks. Maximum 2 hours (2 consecutive blocks). The bot enforces a max_duration_hours: 2 limit in config — any booking request over 2 hours is rejected before the browser opens.
Running It
# Night before: message the Telegram bot
book tomorrow at 9am
# 6:58 AM: cron fires caffeinate (prevents sleep)
# 7:00 AM: cron fires run_tennis.sh
# 7:00 AM + ~90s: Telegram notification with booking result
The thing I appreciate most about this setup is that it runs at 7 AM whether I'm awake or not. I messaged the bot at 11 PM once and had a court confirmation in my Telegram notifications by 7:02.