Share one DB connection per run + cleanups

Open one psycopg2 connection in main() and pass it through pull_data_and_log
and log_to_database. Previously each landing-zone insert opened its own
connection (3-4 handshakes per invocation); now everything runs in a single
session and the three LZ writes share one transaction.

Other cleanups:
- Replace bare `except:` blocks around attraction-field reads with .get()
- Drop the unused col_names computation in the old generic_query
- Move module-level driver code under `if __name__ == "__main__":`
- Parse park_opens / park_closes once instead of three times
- Name columns explicitly in the park-hours query (was SELECT *)
- Use `datestamp` (date column) for the today-freshness check rather
  than reading .date() off the open timestamp

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
wes
2026-05-07 06:36:31 -04:00
co-authored by Claude Opus 4.7
parent 10bdee8e1f
commit 7a92834c8b
+103 -106
View File
@@ -1,12 +1,18 @@
#!/usr/bin/env python3
import json
import logging
import os
from datetime import datetime, date, timedelta
import json, requests, psycopg2, logging, sys
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:
@@ -19,64 +25,44 @@ db_params = {
'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'
'options': '-c search_path=knoebels',
}
LOG_PATH = os.environ.get('LOG_PATH', '/var/log/kuh-no-bowls.log')
park_hours_query = """
SELECT *
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:
# Discord notifications disabled — skip silently.
return
data = {
"content": message
}
try:
response = requests.post(DISCORD_WEBHOOK, json=data)
if response.status_code == 204:
print("Message sent successfully.")
else:
print(f"Failed to send message. Status code: {response.status_code}, Response: {response.text}")
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:
print(f"Error sending message: {e}")
logging.warning("Discord webhook error: %s", e)
def log_to_database(table_name, data):
with psycopg2.connect(**db_params) as conn:
with conn.cursor() as cursor:
columns = len(data[0])
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() * columns)
values=sql.SQL(', ').join(sql.Placeholder() * len(data[0])),
)
cursor.executemany(query, data)
with conn.cursor() as cur:
cur.executemany(query, data)
conn.commit()
def generic_query(q):
ret = []
with psycopg2.connect(**db_params) as conn:
with conn.cursor() as cursor:
cursor.execute(q)
rows = cursor.fetchall()
col_names = [desc[0] for desc in cursor.description]
for row in rows:
ret.append(row)
return ret
def fetch_webpage(url):
HEADERS = {
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',
@@ -89,100 +75,111 @@ def fetch_webpage(url):
'Sec-Fetch-Site': 'none',
'Sec-Fetch-User': '?1',
}
try:
response = requests.get(url, headers=HEADERS)
if response.status_code == 200:
response = requests.get(ATTRACTIONS_IO_ENDPOINT, headers=headers)
response.raise_for_status()
return response.json()
else:
return f"Error: {response.status_code} - Unable to fetch webpage"
except Exception as e:
return f"Error: {str(e)} - Unable to fetch webpage"
def pull_data_and_log(log_park_hours):
json_data = fetch_webpage(ATTRACTIONS_IO_ENDPOINT)
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:
park_opens = json.loads(json_data['entities']['Resort']['records'][0]['OpeningTimes'])['start']
park_closes = json.loads(json_data['entities']['Resort']['records'][0]['OpeningTimes'])['end']
except KeyError as e:
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
logging.debug('Checking that park hours from API are actually occuring today...')
if not datetime.today().date() == datetime.strptime(park_opens, "%Y-%m-%d %H:%M:%S").date():
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
resort_data = [[datetime.now(), json_data['entities']['Resort']['records'][0]['_id'], park_opens, park_closes]]
if log_park_hours:
logging.info('Inserting park hours into LZ_attractions_io_resort...')
log_to_database('LZ_attractions_io_resort', resort_data)
if not (datetime.strptime(park_opens, "%Y-%m-%d %H:%M:%S") - timedelta(minutes=5)) <= datetime.now() <= (datetime.strptime(park_closes, "%Y-%m-%d %H:%M:%S") + timedelta(minutes=5)):
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']:
try:
isOperational = attraction['IsOperational']
except:
isOperational = None
try:
QueueTime = attraction['QueueTime']
except:
QueueTime = None
try:
QueueStatusMessage = attraction['QueueStatusMessage']
except:
QueueStatusMessage = None
try:
IsOpen = attraction['IsOpen']
except:
IsOpen = None
try:
OpeningTimes = json.loads(attraction['OpeningTimes'])
start = OpeningTimes['start']
end = OpeningTimes['end']
except:
start = None
end = None
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(),
])
row = [attraction['_id'], isOperational, QueueTime, QueueStatusMessage, IsOpen, start, end, datetime.now()]
attraction_data.append(row)
queueline_data = []
for queue in json_data['entities']['QueueLine']['records']:
row = [datetime.now(), queue['_id'], queue['QueueTime']]
queueline_data.append(row)
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('LZ_attractions_io', attraction_data)
log_to_database(conn, 'LZ_attractions_io', attraction_data)
logging.debug('Inserting rows into LZ_attractions_io_queuetimes...')
log_to_database('LZ_attractions_io_queuetimes', queueline_data)
log_to_database(conn, 'LZ_attractions_io_queuetimes', queueline_data)
logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s', level=logging.DEBUG, filename=LOG_PATH, filemode="a")
logging.info('--------------------------------------------------------------------------------')
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...')
park_hours_pull = generic_query(park_hours_query)[0]
logging.debug('%s - %s', park_hours_pull[2], park_hours_pull[3])
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
if park_hours_pull[2].date() == date.today():
logging.info('Park hours for today already in DB')
park_opens = park_hours_pull[2]
park_closes = park_hours_pull[3]
if (park_opens - timedelta(minutes=5)) <= datetime.now() <= (park_closes + timedelta(minutes=5)):
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(False)
pull_data_and_log(conn, log_park_hours=False)
else:
logging.info('Park not currently open. Exiting...')
else:
logging.info('Park hours for today not in DB')
pull_data_and_log(True)
if __name__ == '__main__':
main()