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"]
+114 -110
View File
@@ -1,73 +1,68 @@
#!/usr/bin/env python3
from datetime import datetime, date, timedelta
import json, requests, psycopg2, logging, sys
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 = 'https://discord.com/api/webhooks/878651005015842827/o_KXXiPgor-DhZQ9vgL-MNOYb-RDmQCAbuYmgUpsoEOqu3XCfBdUM6rDmF1cMrZg12b0'
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': 'kuhnobowls',
'password': 'saddog095',
'host': '192.168.88.5',
'port': '5432',
'options': '-c search_path=knoebels'
'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 *
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):
# The payload to send to the webhook
data = {
"content": message
}
if not DISCORD_WEBHOOK:
return
try:
# Send the POST request to the webhook URL
response = requests.post(DISCORD_WEBHOOK, json=data)
# Check if the request was successful
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',
@@ -80,102 +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="/home/hoid/git_repos/kuh-no-bowls/knb.log",filemode="a")
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...')
logging.info('--------------------------------------------------------------------------------')
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()
park_hours_pull = generic_query(park_hours_query)[0]
logging.debug('%s - %s', park_hours_pull[2], park_hours_pull[3])
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()
+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