The gym service scrapes the gym website for class reservations. No API, just Selenium clicking buttons in a headless Chrome instance.
It’s ugly, fragile, and works perfectly.
The Problem
My gym has a website where you can reserve classes up to 22 days in advance. Popular classes fill up within minutes of the window opening.
Manual process:
- Check website daily for new classes
- Reserve the ones you want before they fill
- Check back to see if you got off the waitlist
- Hope you didn’t miss anything
This is tedious and error-prone. I wanted automation, but the gym doesn’t have an API.
The Solution
Web scraper that logs in, navigates the calendar, extracts class data, and can reserve/cancel/waitlist classes via HTTP endpoints.
Tech stack:
- Python 3
- Selenium WebDriver
- Chrome (headless, running as sidecar container in k8s)
- S3 for caching results
Endpoints:
1
2
3
4
5
6
7
|
GET /latest - Reserved/waitlisted classes (JSON)
GET /all - All available classes within 22-day window (JSON)
POST /reserve - Reserve a class {date, time, type}
POST /cancel - Cancel a class {date, time, type}
POST /update - Trigger manual scrape
GET /status - Scrape status (in progress / last update time)
GET /logs - Recent logs
|
The Scraping Logic
The gym uses a JavaScript calendar UI. No direct links to classes, everything is dynamic DOM manipulation.
Login flow:
1
2
3
4
5
6
|
def login(self):
self.driver.get("https://gym-website.example.com")
self.driver.find_element(By.ID, "username").send_keys(self.username)
self.driver.find_element(By.ID, "password").send_keys(self.password)
self.driver.find_element(By.ID, "login-button").click()
time.sleep(2) # Wait for redirect
|
Calendar navigation:
1
2
3
4
5
|
def next(self):
# Click "Next Week" button
next_button = self.driver.find_element(By.CLASS_NAME, "fc-next-button")
next_button.click()
time.sleep(1.5) # Wait for calendar to reload
|
Class extraction:
The calendar renders classes as DOM elements with data attributes. Extract everything:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
def available(self, paginate=True):
classes = []
# Find all class elements on current calendar view
class_elements = self.driver.find_elements(By.CLASS_NAME, "fc-event")
for elem in class_elements:
class_type = elem.get_attribute("data-class-type")
date = elem.get_attribute("data-date")
time = elem.get_attribute("data-time")
instructor = elem.get_attribute("data-instructor")
reserved = "reserved" in elem.get_attribute("class")
waitlisted = "waitlist" in elem.get_attribute("class")
full = "full" in elem.get_attribute("class")
classes.append(Class(
Type=class_type,
Date=date,
Time=time,
Instructor=instructor,
Reserved=reserved,
Status="Waitlist" if waitlisted else None,
Full=full
))
if paginate:
self.next() # Move to next week
return classes
|
Multi-page scraping:
The service fetches 3-4 weeks of classes (limited by the 22-day reservation window):
1
2
3
4
5
6
7
8
9
10
11
|
classes = ssp.available() # First week
for i in range(0, 3): # Next 3 weeks
ssp.next()
new_classes = ssp.available(False)
for c in new_classes:
if c.Date <= reservation_limit:
classes.append(c)
else:
break # Stop when beyond reservation window
|
This takes ~15 seconds total. Results cached in S3 and served from memory.
Reservation Logic
Reserving a class requires:
- Navigate to the correct week
- Find the specific class by date + time + type
- Click it
- Handle three possible outcomes:
- Reserved (button click succeeded)
- Waitlisted (class was full, auto-waitlist kicked in)
- Already reserved/waitlisted
Implementation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
def find_and_click_class(self, date, time, class_name, intent="reserve"):
# Navigate to week containing this date
self.navigate_to_week(date)
# Find class element matching date + time + type
class_elem = self.find_class_element(date, time, class_name)
if not class_elem:
raise ValueError(f"Class not found: {class_name} on {date} at {time}")
# Check current status
class_attr = class_elem.get_attribute("class")
if intent == "reserve":
if "reserved" in class_attr:
return "already_reserved"
if "waitlist" in class_attr:
return "already_waitlisted"
# Click to reserve
class_elem.click()
time.sleep(2)
# Check result
updated_elem = self.find_class_element(date, time, class_name)
updated_attr = updated_elem.get_attribute("class")
if "reserved" in updated_attr:
return "reserved"
elif "waitlist" in updated_attr:
return "waitlisted"
else:
return "unknown"
elif intent == "cancel":
if "reserved" not in class_attr:
return "not_reserved"
class_elem.click() # Click again to cancel
time.sleep(2)
return "cancelled"
|
Classes returned as JSON:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
{
"Type": "HIIT",
"Date": "2025-10-15",
"Time": "6:00 AM",
"Info": "High-intensity workout combining cardio and strength",
"Instructor": "John",
"Location": "Studio A",
"Reserved": true,
"Status": null,
"Full": false,
"Position": null,
"GymID": "gym_abc123def456"
}
|
GymID: MD5 hash of {type}_{date}_{time}
. Stable identifier for duplicate detection.
Position: Waitlist position if applicable, extracted from text like “Waitlist (5/4)” → "5/4"
.
Temporary Reservations
When auto-reservation succeeds, the gym service doesn’t immediately have the updated data (requires a full scrape). To avoid the calendar service thinking the reservation failed, we track temporary reservations in memory:
1
2
3
4
5
6
7
8
9
10
11
|
temporary_reservations = {}
if result == "reserved":
temp_key = f"{date}_{time}_{class_type}"
temporary_reservations[temp_key] = {
"Type": class_type,
"Date": date,
"Time": time,
"Reserved": True,
"Temporary": True
}
|
Next full scrape clears temporary reservations and replaces them with real data.
Error Handling
Web scraping is inherently fragile. The service handles:
Timeouts: If a page doesn’t load within 10 seconds, retry once, then fail.
Stale elements: DOM updates between finding an element and clicking it. Retry with fresh lookup.
Login failures: If login fails 3 times, mark service unhealthy and stop retrying (prevents account lockout).
Conflicts: Only one reservation operation at a time. Use a lock to prevent concurrent clicks.
Health Checks
1
2
3
4
5
6
7
8
9
10
11
|
@app.route('/health')
def health():
# Check if last update had an error
if last_error:
return {"status": "unhealthy", "last_error": last_error}, 503
# Check if data is loaded
if goodbits is None:
return {"status": "starting"}, 200
return {"status": "healthy"}, 200
|
K8s liveness probe hits /health
. If it returns 503, the pod restarts.
🤖 Generated with Claude Code
Co-Authored-By: Claude noreply@anthropic.com