import asyncio from datetime import datetime, timedelta import functools import logging import os import pprint import zoneinfo from fastapi import FastAPI, HTTPException import httpx pp = pprint.PrettyPrinter() logger = logging.getLogger("uvicorn.error") eastern = zoneinfo.ZoneInfo("America/New_York") app = FastAPI() 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): 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() return [ { "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"] ], } ] @fallback_handler async def fetch_weather(lat, lon, weather_api_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] return [ { "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 ], } ] @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