twenty-four: gym service

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:

  1. Check website daily for new classes
  2. Reserve the ones you want before they fill
  3. Check back to see if you got off the waitlist
  4. 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:

  1. Navigate to the correct week
  2. Find the specific class by date + time + type
  3. Click it
  4. 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"

Data Format

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.


See also: Part 1: Building with Claude | Part 2: Calendar Service

🤖 Generated with Claude Code

Co-Authored-By: Claude noreply@anthropic.com