trmnl-report/main.py
2025-06-23 06:22:37 -04:00

236 lines
7.5 KiB
Python

import asyncio
from datetime import datetime, timedelta
import functools
import logging
import os
import pprint
import zoneinfo
from fastapi import FastAPI, HTTPException
from cachetools import TTLCache
import httpx
pp = pprint.PrettyPrinter()
logger = logging.getLogger("uvicorn.error")
eastern = zoneinfo.ZoneInfo("America/New_York")
app = FastAPI()
# Initialize caches for 15 minutes
weather_cache = TTLCache(maxsize=100, ttl=900) # 15 minutes
pollen_cache = TTLCache(maxsize=100, ttl=900) # 15 minutes
CONFIG = {
# salem, ma
"zip": "01970",
"lat": "42.3554334",
"lon": "-71.060511",
"weather_api_key": os.environ.get("WEATHER_API_KEY", ""),
"auth_token": os.environ["AUTH_TOKEN"],
}
def format_date(dt):
dt = dt.astimezone(eastern)
return dt.strftime("%a %d").lower()
def format_time(dt):
dt = dt.astimezone(eastern)
return dt.strftime("%I:%M%p").lower()
def format_datetime(dt):
dt = dt.astimezone(eastern)
return f"{format_date(dt)} {format_time(dt)}"
def relative_day_to_date(rel_dt):
dt = datetime.now()
day = timedelta(days=1)
match rel_dt.lower().strip():
case "yesterday":
return format_date(dt - day)
case "today":
return format_date(dt)
case "tomorrow":
return format_date(dt + day)
case passthrough:
return passthrough
def build_daily_data(date, pollen_periods, weather_periods):
daily_data = {}
if date in pollen_periods:
daily_data["pollen"] = pollen_periods[date]["index"]
if date in weather_periods:
weather_data = weather_periods[date]
daily_data.update({
"low": weather_data["low"],
"high": weather_data["high"],
"desc": weather_data["desc"],
"humidity": weather_data["humidity"],
"sunrise": weather_data["sunrise"],
"sunset": weather_data["sunset"],
"pressure": weather_data["pressure"],
})
return daily_data
def fallback_handler(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
try:
result = await func(*args, **kwargs)
except Exception as e:
logger.exception(e)
return []
return result
return wrapper
@fallback_handler
async def fetch_pollen(zipcode):
# Check the cache first
if zipcode in pollen_cache:
return pollen_cache[zipcode]
url = f"https://www.pollen.com/api/forecast/current/pollen/{zipcode}"
headers = {
"User-Agent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/123.0.0.0 Safari/537.36"
),
"Accept": "application/json, text/plain, */*",
"Referer": url,
"Cookie": f"geo={zipcode}",
}
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
data = response.json()
result = [
{
"forecast_date": format_datetime(
datetime.fromisoformat(data["ForecastDate"])
),
"periods": [
{
"index": int(d["Index"] / 12. * 100),
"period": relative_day_to_date(d["Type"]),
}
for d in data["Location"]["periods"]
if d["Type"].lower().strip() in ["today", "tomorrow"]
],
}
]
# Cache the result
pollen_cache[zipcode] = result
return result
@fallback_handler
async def fetch_weather(lat, lon, weather_api_key):
# Check the cache first
cache_key = (lat, lon)
if cache_key in weather_cache:
return weather_cache[cache_key]
url = f"https://api.openweathermap.org/data/3.0/onecall?lat={lat}&lon={lon}&appid={weather_api_key}&units=imperial"
async with httpx.AsyncClient() as client:
response = await client.get(url)
response.raise_for_status()
data = response.json()
current, periods = data["current"], data["daily"][:2]
result = [
{
"forecast_date": format_datetime(datetime.fromtimestamp(current["dt"])),
"current_temp": int(round(current["temp"])),
"current_feels_like": int(round(current["feels_like"])),
"current_humidity": current["humidity"],
"sunrise": format_time(datetime.fromtimestamp(current["sunrise"])),
"sunset": format_time(datetime.fromtimestamp(current["sunset"])),
"current_pressure": current["pressure"],
"current_desc": current["weather"][0]["description"],
"periods": [
{
"low": int(round(p["temp"]["min"])),
"high": int(round(p["temp"]["max"])),
"desc": p["weather"][0]["description"],
"humidity": p["humidity"],
"sunrise": format_time(datetime.fromtimestamp(p["sunrise"])),
"sunset": format_time(datetime.fromtimestamp(p["sunset"])),
"pressure": p["pressure"],
"period": format_date(datetime.fromtimestamp(p["dt"])),
}
for p in periods
],
}
]
# Cache the result
weather_cache[cache_key] = result
return result
@app.get("/")
async def read_root(token: str):
if token != CONFIG["auth_token"]:
raise HTTPException(status_code=403, detail="unauthorized")
[
pollen,
weather,
] = await asyncio.gather(
fetch_pollen(CONFIG["zip"]),
fetch_weather(CONFIG["lat"], CONFIG["lon"], CONFIG["weather_api_key"]),
)
pollen_periods = {p["period"]: p for p in pollen[0]["periods"]} if pollen and pollen[0]["periods"] else {}
weather_periods = {p["period"]: p for p in weather[0]["periods"]} if weather and weather[0]["periods"] else {}
# Add current weather data as a "current" period
if weather and weather[0]:
data = weather[0]
weather_periods["current"] = {
"period": "current",
"temp": data["current_temp"],
"feels_like": data["current_feels_like"],
"humidity": data["current_humidity"],
"pressure": data["current_pressure"],
"desc": data["current_desc"],
"sunrise": data["sunrise"],
"sunset": data["sunset"]
}
today_date = format_date(datetime.now())
tomorrow_date = format_date(datetime.now() + timedelta(days=1))
result = {
"fetched_at": format_datetime(datetime.now()),
"current": {},
"today": {},
"tomorrow": {},
}
if "current" in weather_periods:
weather_data = weather_periods["current"]
result["current"] = {
"temp": weather_data["temp"],
"feels_like": weather_data["feels_like"],
"desc": weather_data["desc"],
"humidity": weather_data["humidity"],
"pressure": weather_data["pressure"]
}
today_data = build_daily_data(today_date, pollen_periods, weather_periods)
if today_data:
result["today"] = today_data
tomorrow_data = build_daily_data(tomorrow_date, pollen_periods, weather_periods)
if tomorrow_data:
result["tomorrow"] = tomorrow_data
return result