Skip to content

Instantly share code, notes, and snippets.

@sfuller
Created February 22, 2022 01:31
Show Gist options
  • Save sfuller/b412bee26784a8c084cf5c62bed6b141 to your computer and use it in GitHub Desktop.
Save sfuller/b412bee26784a8c084cf5c62bed6b141 to your computer and use it in GitHub Desktop.
Golden gate picnic table reservation site availability checker
#!/usr/bin/env python3
# pip install requests beautifulsoup4
import argparse
import re
import datetime
import time
import pickle
from typing import Iterable, List, Dict, Tuple
import requests
from bs4 import BeautifulSoup
BOOK_ID_PATTERN = re.compile(r'btnBook(\d\d\d\d)(\d\d)(\d\d)')
def make_argparser() -> argparse.ArgumentParser:
argparser = argparse.ArgumentParser()
argparser.add_argument('--cached', action='store_true', help='Use cached results instead of scraping.')
argparser.add_argument('--urls', action='store_true', help='Print booking urls in the summary.')
argparser.add_argument('--weekdays', help='Comma separated list of weekdays to filter to. Mon-Sun, 0-6.')
argparser.add_argument('--months', default=1, type=int, help='Number of months in the future to scrape.')
return argparser
class Facility(object):
def __init__(self, id_, name: str):
self.id = id_
self.name = name
FACILTIES: Iterable[Facility] = (
Facility(1063, 'Esprit Park Picnic Area - Lawn'),
Facility(1065, 'Esprit Park Picnic Area - North East 2 Tables'),
Facility(1068, 'Esprit Park Picnic Area - South East 1 Table'),
Facility(1202, 'GGP - 14th Avenue East Picnic Area'),
Facility(78, 'GGP - Bunny Meadow Picnic Area'),
Facility(80, 'GGP - Dahlia Dell Picnic Area'),
Facility(87, 'GGP - Doughboy Meadow Picnic Area'),
Facility(90, 'GGP - Elk Glen Picnic Area'),
Facility(92, 'GGP - George Washington Grove Picnic Area'),
Facility(94, 'GGP - Hellman Hollow Picnic Area - Table 1 & 2'),
Facility(103, 'GGP - Hellman Hollow Picnic Area - Table 3 & 4'),
Facility(96, 'GGP - Hellman Hollow Picnic Area - Table 5 & 6'),
Facility(97, 'GGP - Hellman Hollow Picnic Area - Table 7, 8, 9'),
Facility(98, 'GGP - Hellman Hollow Picnic Area - Table 10, 11, 12'),
Facility(99, 'GGP - Hellman Hollow Picnic Area - Table 13 & 14'),
Facility(100, 'GGP - Hellman Hollow Picnic Area - Table 15 & 16'),
Facility(101, 'GGP - Hellman Hollow Picnic Area - Table 17 & 18'),
Facility(102, 'GGP - Hellman Hollow Picnic Area - Table 19 & 20'),
Facility(105, 'GGP - Hoover Redwood Grove Picnic Area'),
Facility(111, 'GGP - Lindley Meadow Picnic Area - Table 1'),
Facility(118, 'GGP - Lindley Meadow Picnic Area - Table 2'),
Facility(106, 'GGP - Lindley Meadow Picnic Area - Table 3 & 4'),
Facility(119, 'GGP - Lindley Meadow Picnic Area - Table 5'),
Facility(107, 'GGP - Lindley Meadow Picnic Area - Table 6 & 7'),
Facility(108, 'GGP - Lindley Meadow Picnic Area - Table 8 & 9'),
Facility(109, 'GGP - Lindley Meadow Picnic Area - Table 10 & 11'),
Facility(110, 'GGP - Lindley Meadow Picnic Area - Table 12 & 13'),
Facility(113, 'GGP - Lindley Meadow Picnic Area - Table 14'),
Facility(114, 'GGP - Lindley Meadow Picnic Area - Table 15'),
Facility(115, 'GGP - Lindley Meadow Picnic Area - Table 16'),
Facility(116, 'GGP - Lindley Meadow Picnic Area - Table 17'),
Facility(120, 'GGP - Marx Meadow Picnic Area - Table 1 & 2'),
Facility(123, 'GGP - Marx Meadow Picnic Area - Table 3'),
Facility(124, 'GGP - Marx Meadow Picnic Area - Table 4'),
Facility(125, 'GGP - Marx Meadow Picnic Area - Table 5'),
Facility(126, 'GGP - Marx Meadow Picnic Area - Table 6'),
Facility(122, 'GGP - Marx Meadow Picnic Area - Table 7 & 8'),
Facility(131, 'GGP - Old Speedway Meadow Picnic Area'),
Facility(133, 'GGP - Peacock Meadow Picnic Area'),
Facility(134, 'GGP - Pioneer East Meadow Picnic Area'),
Facility(135, 'GGP - Pioneer Log Cabin Picnic Area'),
Facility(139, 'GGP - Stow Lake Boathouse Picnic Area'),
Facility(2317, 'GGP - Stow Lake South Side Lawn'),
Facility(141, 'GGP – Strawberry Hill Near Chinese Pavilion'),
Facility(142, 'GGP – Strawberry Hill Top Picnic Area'),
Facility(1555, 'John McLaren Park John F. Shelley Drive Picnic Area - Picnic Area A, West 7 tables + counter'),
Facility(1562, 'John McLaren Park John F. Shelley Drive Picnic Area - Picnic Area B, East 5 tables + counter'),
Facility(1778, 'Mountain Lake Park Picnic Area - 8th Ave 2 Tables'),
Facility(1773, 'Mountain Lake Park Picnic Area - 9th Ave Shelter'),
Facility(1776, 'Mountain Lake Park Picnic Area - 10th Ave Meadow'),
Facility(1777, 'Mountain Lake Park Picnic Area - 11th Ave Meadow'),
Facility(1779, 'Mountain Lake Park Picnic Area - 12th Ave 1 Table'),
)
FACILTIES_BY_ID = {facility.id: facility for facility in FACILTIES}
WEEKDAY_NAMES = {
0: 'Monday',
1: 'Tuesday',
2: 'Wednesday',
3: 'Thursday',
4: 'Friday',
5: 'Saturday',
6: 'Sunday'
}
def scrape_month(facility_id, year, month) -> Dict[int, str]:
startyear = year
endyear = year
response = requests.get(f'https://apm.activecommunities.com/sfrecpark/facility_search?IsCalendar=true'
f'&facility_id={facility_id}&year={year}&month={month}'
f'&startyear={startyear}&endyear={endyear}')
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
table = soup.html.body\
.find('div', id='an-background')\
.find('div', id='an-wrapper')\
.find('div', id='an-contentarea')\
.find('form', id='IronPointForm_1')\
.find('div', id='ctl06_ctlFacilityCalendar_divCalendar')\
.find('div', id='ctl06_ctlFacilityCalendar_pnlCalendarAll')\
.find('div', id='ctl06_ctlFacilityCalendar_pnlCalendarRefresh') \
.find('div', id='ctl06_ctlFacilityCalendar_pnlCalendar') \
.find('table', id='ctl06_ctlFacilityCalendar_tblMain')
available_days: Dict[int, str] = {}
all_anchors = table.find_all('a')
for anchor in all_anchors:
anchor_id = anchor.get('id')
if not anchor_id:
continue
match = BOOK_ID_PATTERN.match(anchor_id)
if match:
day = match.group(3)
try:
day_num = int(day)
except ValueError:
continue
available_days[day_num] = anchor.get('href', '')
return available_days
def get_dates(num: int) -> List[datetime.date]:
today = datetime.date.today()
dates = [today]
current_month = today.month
current_year = today.year
for i in range(num):
current_month += 1
if current_month > 12:
current_month = 1
current_year += 1
dates.append(datetime.date(year=current_year, month=current_month, day=1))
return dates
def scrape(args):
dates = get_dates(args.months)
available_days_by_facility: Dict[int, List[Tuple[datetime.date, str]]] = {}
for facility in FACILTIES:
for date in dates:
# Be considerate, wait between requests
time.sleep(1.0)
print(f'GET: Month: {date.month}, Facility: "{facility.name}"')
available_days = scrape_month(facility.id, date.year, date.month)
print(f'\tFound {len(available_days)} spot(s).')
availabilities_list = available_days_by_facility.setdefault(facility.id, [])
for day, href in available_days.items():
availabilities_list.append((datetime.date(date.year, date.month, day), href))
with open('results.bin', 'wb') as f:
pickle.dump(available_days_by_facility, f)
print_summary(args, available_days_by_facility)
def display_cached(args):
with open('results.bin', 'rb') as f:
data = pickle.load(f)
print_summary(args, data)
def datekey(date: datetime.date) -> int:
return date.year * 10000 + date.month * 100 + date.day
def print_summary(args, available_days_by_facility: Dict[int, List[Tuple[datetime.date, str]]]):
weekdays = None
if args.weekdays:
weekdays = {int(day) for day in args.weekdays.split(',')}
print('--- SUMMARY ---')
for facility in FACILTIES:
facility_results = available_days_by_facility.get(facility.id)
if not facility_results:
continue
if weekdays:
facility_results = [t for t in facility_results if t[0].weekday() in weekdays]
if len(facility_results) == 0:
continue
print(f'🌳 {facility.name}')
for date, href in sorted(facility_results, key=lambda t: datekey(t[0])):
entry = f'\t🧺 {WEEKDAY_NAMES[date.weekday()]}\t{date.month}/{date.day} '
if args.urls:
print(f'{entry}\t{href}')
else:
print(entry)
if __name__ == '__main__':
args = make_argparser().parse_args()
if args.cached:
display_cached(args)
else:
scrape(args)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment