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"]
+120 -116
View File
@@ -1,73 +1,68 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from datetime import datetime, date, timedelta import json
import json, requests, psycopg2, logging, sys import logging
import os
import sys
from datetime import date, datetime, timedelta
import psycopg2
import requests
from psycopg2 import sql from psycopg2 import sql
ATTRACTIONS_IO_ENDPOINT = "https://live-data.attractions.io/72f0ea9e-d196-508a-bee8-cce62c3228c7.json" 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 = { db_params = {
'dbname': 'gp0', 'dbname': 'gp0',
'user': 'kuhnobowls', 'user': os.environ.get('DB_USER', 'kuhnobowls'),
'password': 'saddog095', 'password': DB_PASSWORD,
'host': '192.168.88.5', 'host': os.environ.get('DB_HOST', '192.168.88.9'),
'port': '5432', 'port': os.environ.get('DB_PORT', '5432'),
'options': '-c search_path=knoebels' 'options': '-c search_path=knoebels',
} }
park_hours_query = """ PARK_HOURS_QUERY = """
SELECT * SELECT datestamp, _id, open, close, closed
FROM knoebels."LZ_attractions_io_resort" FROM knoebels."LZ_attractions_io_resort"
ORDER BY datestamp DESC ORDER BY datestamp DESC
LIMIT 1 LIMIT 1
""" """
API_TIME_FMT = "%Y-%m-%d %H:%M:%S"
def send_to_discord(message): def send_to_discord(message):
# The payload to send to the webhook if not DISCORD_WEBHOOK:
data = { return
"content": message
}
try: try:
# Send the POST request to the webhook URL response = requests.post(DISCORD_WEBHOOK, json={"content": message})
response = requests.post(DISCORD_WEBHOOK, json=data) if response.status_code != 204:
logging.warning("Discord webhook returned %s: %s", response.status_code, response.text)
# 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}")
except requests.exceptions.RequestException as e: 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])
query = sql.SQL("INSERT INTO {table} VALUES ({values})").format(
table=sql.Identifier(table_name),
values=sql.SQL(', ').join(sql.Placeholder() * columns)
)
cursor.executemany(query, data) def log_to_database(conn, table_name, data):
conn.commit() 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 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: def fetch_attractions_payload():
ret.append(row) headers = {
return ret
def fetch_webpage(url):
HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0', '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': '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', 'Accept-Language': 'en-US,en;q=0.5',
@@ -80,102 +75,111 @@ def fetch_webpage(url):
'Sec-Fetch-Site': 'none', 'Sec-Fetch-Site': 'none',
'Sec-Fetch-User': '?1', '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: try:
response = requests.get(url, headers=HEADERS) parsed = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return None, None
return parsed.get('start'), parsed.get('end')
if response.status_code == 200:
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): 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)
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?') logging.error('Unable to pull park hours. Is the park open today?')
return return
logging.debug('Checking that park hours from API are actually occuring today...') opens_dt = datetime.strptime(park_opens, API_TIME_FMT)
if not datetime.today().date() == datetime.strptime(park_opens, "%Y-%m-%d %H:%M:%S").date(): 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...') logging.error('API not returning park hours for today yet. Exiting...')
return return
resort_data = [[datetime.now(), json_data['entities']['Resort']['records'][0]['_id'], park_opens, park_closes]]
if log_park_hours: if log_park_hours:
logging.info('Inserting park hours into LZ_attractions_io_resort...') logging.info('Inserting park hours into LZ_attractions_io_resort...')
log_to_database('LZ_attractions_io_resort', resort_data) log_to_database(conn, 'LZ_attractions_io_resort',
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)): [[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...') logging.info('Park not open now. Exiting...')
return return
attraction_data = [] attraction_data = []
for attraction in json_data['entities']['Item']['records']: for attraction in json_data['entities']['Item']['records']:
try: start, end = _attraction_opening_times(attraction.get('OpeningTimes'))
isOperational = attraction['IsOperational'] attraction_data.append([
except: attraction['_id'],
isOperational = None attraction.get('IsOperational'),
try: attraction.get('QueueTime'),
QueueTime = attraction['QueueTime'] attraction.get('QueueStatusMessage'),
except: attraction.get('IsOpen'),
QueueTime = None start,
try: end,
QueueStatusMessage = attraction['QueueStatusMessage'] datetime.now(),
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
row = [attraction['_id'], isOperational, QueueTime, QueueStatusMessage, IsOpen, start, end, datetime.now()] queueline_data = [
attraction_data.append(row) [datetime.now(), queue['_id'], queue['QueueTime']]
for queue in json_data['entities']['QueueLine']['records']
queueline_data = [] ]
for queue in json_data['entities']['QueueLine']['records']:
row = [datetime.now(), queue['_id'], queue['QueueTime']]
queueline_data.append(row)
logging.debug('Inserting rows into LZ_attractions_io...') 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...') 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('--------------------------------------------------------------------------------') with psycopg2.connect(**db_params) as conn:
logging.info('Fetching most recent park hours entry from DB...') with conn.cursor() as cur:
cur.execute(PARK_HOURS_QUERY)
row = cur.fetchone()
park_hours_pull = generic_query(park_hours_query)[0] if not row:
logging.debug('%s - %s', park_hours_pull[2], park_hours_pull[3]) logging.info('No park hours rows in DB — fetching from API')
pull_data_and_log(conn, log_park_hours=True)
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