#!/usr/bin/env python3 import json import logging import os import sys from datetime import date, datetime, timedelta import psycopg2 import requests from psycopg2 import sql ATTRACTIONS_IO_ENDPOINT = "https://live-data.attractions.io/72f0ea9e-d196-508a-bee8-cce62c3228c7.json" DISCORD_WEBHOOK = os.environ.get('DISCORD_WEBHOOK', '').strip() LOG_PATH = os.environ.get('LOG_PATH', '/var/log/kuh-no-bowls.log') DB_PASSWORD = os.environ.get('DB_PASSWORD') if not DB_PASSWORD: sys.stderr.write("ERROR: DB_PASSWORD env var is required\n") sys.exit(1) db_params = { 'dbname': 'gp0', 'user': os.environ.get('DB_USER', 'kuhnobowls'), 'password': DB_PASSWORD, 'host': os.environ.get('DB_HOST', '192.168.88.9'), 'port': os.environ.get('DB_PORT', '5432'), 'options': '-c search_path=knoebels', } PARK_HOURS_QUERY = """ SELECT datestamp, _id, open, close, closed FROM knoebels."LZ_attractions_io_resort" ORDER BY datestamp DESC LIMIT 1 """ API_TIME_FMT = "%Y-%m-%d %H:%M:%S" def send_to_discord(message): if not DISCORD_WEBHOOK: return try: response = requests.post(DISCORD_WEBHOOK, json={"content": message}) if response.status_code != 204: logging.warning("Discord webhook returned %s: %s", response.status_code, response.text) except requests.exceptions.RequestException as e: logging.warning("Discord webhook error: %s", e) def log_to_database(conn, table_name, data): if not data: return query = sql.SQL("INSERT INTO {table} VALUES ({values})").format( table=sql.Identifier(table_name), values=sql.SQL(', ').join(sql.Placeholder() * len(data[0])), ) with conn.cursor() as cur: cur.executemany(query, data) conn.commit() def fetch_attractions_payload(): headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', 'Accept-Language': 'en-US,en;q=0.5', 'DNT': '1', 'Sec-GPC': '1', 'Connection': 'keep-alive', 'Upgrade-Insecure-Requests': '1', 'Sec-Fetch-Dest': 'document', 'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-Site': 'none', 'Sec-Fetch-User': '?1', } response = requests.get(ATTRACTIONS_IO_ENDPOINT, headers=headers) response.raise_for_status() return response.json() def _attraction_opening_times(raw): if not raw: return None, None try: parsed = json.loads(raw) except (json.JSONDecodeError, TypeError): return None, None return parsed.get('start'), parsed.get('end') def pull_data_and_log(conn, log_park_hours): json_data = fetch_attractions_payload() logging.debug('Parsing park hours...') try: opening_raw = json_data['entities']['Resort']['records'][0]['OpeningTimes'] resort_id = json_data['entities']['Resort']['records'][0]['_id'] opening_times = json.loads(opening_raw) park_opens = opening_times['start'] park_closes = opening_times['end'] except (KeyError, IndexError, json.JSONDecodeError): logging.error('Unable to pull park hours. Is the park open today?') return opens_dt = datetime.strptime(park_opens, API_TIME_FMT) closes_dt = datetime.strptime(park_closes, API_TIME_FMT) if datetime.today().date() != opens_dt.date(): logging.error('API not returning park hours for today yet. Exiting...') return if log_park_hours: logging.info('Inserting park hours into LZ_attractions_io_resort...') log_to_database(conn, 'LZ_attractions_io_resort', [[datetime.now(), resort_id, park_opens, park_closes]]) if not (opens_dt - timedelta(minutes=5)) <= datetime.now() <= (closes_dt + timedelta(minutes=5)): logging.info('Park not open now. Exiting...') return attraction_data = [] for attraction in json_data['entities']['Item']['records']: start, end = _attraction_opening_times(attraction.get('OpeningTimes')) attraction_data.append([ attraction['_id'], attraction.get('IsOperational'), attraction.get('QueueTime'), attraction.get('QueueStatusMessage'), attraction.get('IsOpen'), start, end, datetime.now(), ]) queueline_data = [ [datetime.now(), queue['_id'], queue['QueueTime']] for queue in json_data['entities']['QueueLine']['records'] ] logging.debug('Inserting rows into LZ_attractions_io...') log_to_database(conn, 'LZ_attractions_io', attraction_data) logging.debug('Inserting rows into LZ_attractions_io_queuetimes...') log_to_database(conn, 'LZ_attractions_io_queuetimes', queueline_data) def main(): logging.basicConfig( format='%(asctime)s %(levelname)s %(message)s', level=logging.DEBUG, filename=LOG_PATH, filemode="a", ) logging.info('-' * 80) logging.info('Fetching most recent park hours entry from DB...') with psycopg2.connect(**db_params) as conn: with conn.cursor() as cur: cur.execute(PARK_HOURS_QUERY) row = cur.fetchone() if not row: logging.info('No park hours rows in DB — fetching from API') pull_data_and_log(conn, log_park_hours=True) return datestamp, _id, open_ts, close_ts, _closed = row logging.debug('%s - %s', open_ts, close_ts) if datestamp != date.today(): logging.info('Park hours for today not in DB') pull_data_and_log(conn, log_park_hours=True) return logging.info('Park hours for today already in DB') if (open_ts - timedelta(minutes=5)) <= datetime.now() <= (close_ts + timedelta(minutes=5)): logging.info('Park is open! Pulling data from API...') pull_data_and_log(conn, log_park_hours=False) else: logging.info('Park not currently open. Exiting...') if __name__ == '__main__': main()