Compare commits

..
2 Commits
Author SHA1 Message Date
wesandClaude Opus 4.7 7a92834c8b 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>
2026-05-07 06:36:31 -04:00
wes 10bdee8e1f Containerize scraper and externalize credentials
Move DB host/user/password, Discord webhook, and log path to env vars
so the script can be deployed via Docker compose. Discord notifications
are now skipped silently when DISCORD_WEBHOOK is unset (the old
hardcoded webhook is removed pending rotation). Adds Dockerfile,
docker-compose.yml, requirements.txt, .env.example, and .gitignore for
the cyrion deployment.
2026-05-06 23:20:12 -04:00
6 changed files with 234 additions and 181 deletions
+16
View File
@@ -0,0 +1,16 @@
# Knoebels scraper environment configuration.
# Copy to .env on the deploy host and fill in real values.
# Never commit the real .env.
# PostgreSQL connection (phlegethon by default)
DB_HOST=192.168.88.9
DB_PORT=5432
DB_USER=kuhnobowls
DB_PASSWORD=
# Optional Discord webhook for notifications.
# Leave empty/unset to skip Discord calls entirely.
DISCORD_WEBHOOK=
# Log path inside the container. Mapped to the knb_logs named volume by compose.
LOG_PATH=/var/log/kuh-no-bowls/kuh-no-bowls.log
+7
View File
@@ -0,0 +1,7 @@
.env
__pycache__/
*.pyc
.venv/
venv/
historical_data/
*.log
+10
View File
@@ -0,0 +1,10 @@
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY attractions_api_logger.py .
ENTRYPOINT ["python", "-u", "attractions_api_logger.py"]
+185 -181
View File
@@ -1,181 +1,185 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from datetime import datetime, date, timedelta import json
import json, requests, psycopg2, logging, sys import logging
from psycopg2 import sql import os
import sys
ATTRACTIONS_IO_ENDPOINT = "https://live-data.attractions.io/72f0ea9e-d196-508a-bee8-cce62c3228c7.json" from datetime import date, datetime, timedelta
DISCORD_WEBHOOK = 'https://discord.com/api/webhooks/878651005015842827/o_KXXiPgor-DhZQ9vgL-MNOYb-RDmQCAbuYmgUpsoEOqu3XCfBdUM6rDmF1cMrZg12b0'
import psycopg2
db_params = { import requests
'dbname': 'gp0', from psycopg2 import sql
'user': 'kuhnobowls',
'password': 'saddog095', ATTRACTIONS_IO_ENDPOINT = "https://live-data.attractions.io/72f0ea9e-d196-508a-bee8-cce62c3228c7.json"
'host': '192.168.88.5', DISCORD_WEBHOOK = os.environ.get('DISCORD_WEBHOOK', '').strip()
'port': '5432', LOG_PATH = os.environ.get('LOG_PATH', '/var/log/kuh-no-bowls.log')
'options': '-c search_path=knoebels'
} DB_PASSWORD = os.environ.get('DB_PASSWORD')
if not DB_PASSWORD:
park_hours_query = """ sys.stderr.write("ERROR: DB_PASSWORD env var is required\n")
SELECT * sys.exit(1)
FROM knoebels."LZ_attractions_io_resort"
ORDER BY datestamp DESC db_params = {
LIMIT 1 'dbname': 'gp0',
""" 'user': os.environ.get('DB_USER', 'kuhnobowls'),
'password': DB_PASSWORD,
def send_to_discord(message): 'host': os.environ.get('DB_HOST', '192.168.88.9'),
# The payload to send to the webhook 'port': os.environ.get('DB_PORT', '5432'),
data = { 'options': '-c search_path=knoebels',
"content": message }
}
PARK_HOURS_QUERY = """
try: SELECT datestamp, _id, open, close, closed
# Send the POST request to the webhook URL FROM knoebels."LZ_attractions_io_resort"
response = requests.post(DISCORD_WEBHOOK, json=data) ORDER BY datestamp DESC
LIMIT 1
# Check if the request was successful """
if response.status_code == 204:
print("Message sent successfully.") API_TIME_FMT = "%Y-%m-%d %H:%M:%S"
else:
print(f"Failed to send message. Status code: {response.status_code}, Response: {response.text}")
except requests.exceptions.RequestException as e: def send_to_discord(message):
print(f"Error sending message: {e}") if not DISCORD_WEBHOOK:
return
def log_to_database(table_name, data): try:
with psycopg2.connect(**db_params) as conn: response = requests.post(DISCORD_WEBHOOK, json={"content": message})
with conn.cursor() as cursor: if response.status_code != 204:
columns = len(data[0]) logging.warning("Discord webhook returned %s: %s", response.status_code, response.text)
query = sql.SQL("INSERT INTO {table} VALUES ({values})").format( except requests.exceptions.RequestException as e:
table=sql.Identifier(table_name), logging.warning("Discord webhook error: %s", e)
values=sql.SQL(', ').join(sql.Placeholder() * columns)
)
def log_to_database(conn, table_name, data):
cursor.executemany(query, data) if not data:
conn.commit() return
query = sql.SQL("INSERT INTO {table} VALUES ({values})").format(
def generic_query(q): table=sql.Identifier(table_name),
ret = [] values=sql.SQL(', ').join(sql.Placeholder() * len(data[0])),
with psycopg2.connect(**db_params) as conn: )
with conn.cursor() as cursor: with conn.cursor() as cur:
cursor.execute(q) cur.executemany(query, data)
rows = cursor.fetchall() conn.commit()
col_names = [desc[0] for desc in cursor.description]
for row in rows: def fetch_attractions_payload():
ret.append(row) headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0',
return ret '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',
def fetch_webpage(url): 'DNT': '1',
HEADERS = { 'Sec-GPC': '1',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0', 'Connection': 'keep-alive',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', 'Upgrade-Insecure-Requests': '1',
'Accept-Language': 'en-US,en;q=0.5', 'Sec-Fetch-Dest': 'document',
'DNT': '1', 'Sec-Fetch-Mode': 'navigate',
'Sec-GPC': '1', 'Sec-Fetch-Site': 'none',
'Connection': 'keep-alive', 'Sec-Fetch-User': '?1',
'Upgrade-Insecure-Requests': '1', }
'Sec-Fetch-Dest': 'document', response = requests.get(ATTRACTIONS_IO_ENDPOINT, headers=headers)
'Sec-Fetch-Mode': 'navigate', response.raise_for_status()
'Sec-Fetch-Site': 'none', return response.json()
'Sec-Fetch-User': '?1',
}
try: def _attraction_opening_times(raw):
response = requests.get(url, headers=HEADERS) if not raw:
return None, None
if response.status_code == 200: try:
return response.json() parsed = json.loads(raw)
else: except (json.JSONDecodeError, TypeError):
return f"Error: {response.status_code} - Unable to fetch webpage" return None, None
except Exception as e: return parsed.get('start'), parsed.get('end')
return f"Error: {str(e)} - Unable to fetch webpage"
def pull_data_and_log(log_park_hours): def pull_data_and_log(conn, log_park_hours):
json_data = fetch_webpage(ATTRACTIONS_IO_ENDPOINT) json_data = fetch_attractions_payload()
logging.debug('Parsing park hours...') logging.debug('Parsing park hours...')
try: try:
park_opens = json.loads(json_data['entities']['Resort']['records'][0]['OpeningTimes'])['start'] opening_raw = json_data['entities']['Resort']['records'][0]['OpeningTimes']
park_closes = json.loads(json_data['entities']['Resort']['records'][0]['OpeningTimes'])['end'] resort_id = json_data['entities']['Resort']['records'][0]['_id']
except KeyError as e: opening_times = json.loads(opening_raw)
logging.error('Unable to pull park hours. Is the park open today?') park_opens = opening_times['start']
return park_closes = opening_times['end']
except (KeyError, IndexError, json.JSONDecodeError):
logging.debug('Checking that park hours from API are actually occuring today...') logging.error('Unable to pull park hours. Is the park open today?')
if not datetime.today().date() == datetime.strptime(park_opens, "%Y-%m-%d %H:%M:%S").date(): return
logging.error('API not returning park hours for today yet. Exiting...')
return opens_dt = datetime.strptime(park_opens, API_TIME_FMT)
closes_dt = datetime.strptime(park_closes, API_TIME_FMT)
resort_data = [[datetime.now(), json_data['entities']['Resort']['records'][0]['_id'], park_opens, park_closes]]
if log_park_hours: if datetime.today().date() != opens_dt.date():
logging.info('Inserting park hours into LZ_attractions_io_resort...') logging.error('API not returning park hours for today yet. Exiting...')
log_to_database('LZ_attractions_io_resort', resort_data) return
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)):
logging.info('Park not open now. Exiting...') if log_park_hours:
return logging.info('Inserting park hours into LZ_attractions_io_resort...')
log_to_database(conn, 'LZ_attractions_io_resort',
attraction_data = [] [[datetime.now(), resort_id, park_opens, park_closes]])
if not (opens_dt - timedelta(minutes=5)) <= datetime.now() <= (closes_dt + timedelta(minutes=5)):
for attraction in json_data['entities']['Item']['records']: logging.info('Park not open now. Exiting...')
try: return
isOperational = attraction['IsOperational']
except: attraction_data = []
isOperational = None for attraction in json_data['entities']['Item']['records']:
try: start, end = _attraction_opening_times(attraction.get('OpeningTimes'))
QueueTime = attraction['QueueTime'] attraction_data.append([
except: attraction['_id'],
QueueTime = None attraction.get('IsOperational'),
try: attraction.get('QueueTime'),
QueueStatusMessage = attraction['QueueStatusMessage'] attraction.get('QueueStatusMessage'),
except: attraction.get('IsOpen'),
QueueStatusMessage = None start,
try: end,
IsOpen = attraction['IsOpen'] datetime.now(),
except: ])
IsOpen = None
try: queueline_data = [
OpeningTimes = json.loads(attraction['OpeningTimes']) [datetime.now(), queue['_id'], queue['QueueTime']]
start = OpeningTimes['start'] for queue in json_data['entities']['QueueLine']['records']
end = OpeningTimes['end'] ]
except:
start = None logging.debug('Inserting rows into LZ_attractions_io...')
end = None log_to_database(conn, 'LZ_attractions_io', attraction_data)
row = [attraction['_id'], isOperational, QueueTime, QueueStatusMessage, IsOpen, start, end, datetime.now()] logging.debug('Inserting rows into LZ_attractions_io_queuetimes...')
attraction_data.append(row) log_to_database(conn, 'LZ_attractions_io_queuetimes', queueline_data)
queueline_data = []
def main():
for queue in json_data['entities']['QueueLine']['records']: logging.basicConfig(
row = [datetime.now(), queue['_id'], queue['QueueTime']] format='%(asctime)s %(levelname)s %(message)s',
queueline_data.append(row) level=logging.DEBUG,
filename=LOG_PATH,
logging.debug('Inserting rows into LZ_attractions_io...') filemode="a",
log_to_database('LZ_attractions_io', attraction_data) )
logging.info('-' * 80)
logging.debug('Inserting rows into LZ_attractions_io_queuetimes...') logging.info('Fetching most recent park hours entry from DB...')
log_to_database('LZ_attractions_io_queuetimes', queueline_data)
with psycopg2.connect(**db_params) as conn:
with conn.cursor() as cur:
logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s', level=logging.DEBUG, filename="/home/hoid/git_repos/kuh-no-bowls/knb.log",filemode="a") cur.execute(PARK_HOURS_QUERY)
row = cur.fetchone()
logging.info('--------------------------------------------------------------------------------')
logging.info('Fetching most recent park hours entry from DB...') if not row:
logging.info('No park hours rows in DB — fetching from API')
park_hours_pull = generic_query(park_hours_query)[0] pull_data_and_log(conn, log_park_hours=True)
logging.debug('%s - %s', park_hours_pull[2], park_hours_pull[3]) return
if park_hours_pull[2].date() == date.today(): datestamp, _id, open_ts, close_ts, _closed = row
logging.info('Park hours for today already in DB') logging.debug('%s - %s', open_ts, close_ts)
park_opens = park_hours_pull[2]
park_closes = park_hours_pull[3] if datestamp != date.today():
if (park_opens - timedelta(minutes=5)) <= datetime.now() <= (park_closes + timedelta(minutes=5)): logging.info('Park hours for today not in DB')
logging.info('Park is open! Pulling data from API...') pull_data_and_log(conn, log_park_hours=True)
pull_data_and_log(False) return
else:
logging.info('Park not currently open. Exiting...') logging.info('Park hours for today already in DB')
else: if (open_ts - timedelta(minutes=5)) <= datetime.now() <= (close_ts + timedelta(minutes=5)):
logging.info('Park hours for today not in DB') logging.info('Park is open! Pulling data from API...')
pull_data_and_log(True) pull_data_and_log(conn, log_park_hours=False)
else:
logging.info('Park not currently open. Exiting...')
if __name__ == '__main__':
main()
+14
View File
@@ -0,0 +1,14 @@
services:
scraper:
build: .
image: kuh-no-bowls:latest
container_name: kuh-no-bowls-scraper
env_file: .env
environment:
LOG_PATH: /var/log/kuh-no-bowls/kuh-no-bowls.log
TZ: America/New_York
volumes:
- knb_logs:/var/log/kuh-no-bowls
volumes:
knb_logs:
+2
View File
@@ -0,0 +1,2 @@
requests
psycopg2-binary