206 lines
7.1 KiB
Python
206 lines
7.1 KiB
Python
import sys
|
|
from pathlib import Path
|
|
from typing import ClassVar
|
|
|
|
import pendulum
|
|
import requests
|
|
from pydantic import Field
|
|
from pydantic_settings import BaseSettings, CliApp
|
|
|
|
|
|
class App(BaseSettings):
|
|
"""Application settings for weather SVG generator."""
|
|
|
|
latitude: float = Field(
|
|
default=48.7519,
|
|
description="Latitude for weather location (default: Bellingham, WA)"
|
|
)
|
|
longitude: float = Field(
|
|
default=-122.4787,
|
|
description="Longitude for weather location (default: Bellingham, WA)"
|
|
)
|
|
grad_date: str = Field(
|
|
default="2023-12-14",
|
|
description="Graduation date in YYYY-MM-DD format"
|
|
)
|
|
template_path: Path = Field(
|
|
default=Path("template.svg"),
|
|
description="Path to template SVG file"
|
|
)
|
|
output_path: Path = Field(
|
|
default=Path("chat.svg"),
|
|
description="Path to output SVG file"
|
|
)
|
|
|
|
|
|
# Weather icon to emoji mapping (based on weather.gov icon URLs)
|
|
WEATHER_EMOJIS: ClassVar = {
|
|
"skc": "☀️", # Sky Clear
|
|
"few": "🌤", # Few Clouds
|
|
"sct": "⛅", # Scattered Clouds
|
|
"bkn": "🌥", # Broken Clouds
|
|
"ovc": "☁️", # Overcast
|
|
"wind_skc": "💨", # Windy and Clear
|
|
"wind_few": "💨", # Windy and Few Clouds
|
|
"wind_sct": "💨", # Windy and Scattered Clouds
|
|
"wind_bkn": "💨", # Windy and Broken Clouds
|
|
"wind_ovc": "💨", # Windy and Overcast
|
|
"snow": "❄️", # Snow
|
|
"rain_snow": "🌨", # Rain/Snow
|
|
"rain_sleet": "🌨", # Rain/Sleet
|
|
"snow_sleet": "🌨", # Snow/Sleet
|
|
"fzra": "🌧", # Freezing Rain
|
|
"rain_fzra": "🌧", # Rain/Freezing Rain
|
|
"snow_fzra": "🌨", # Snow/Freezing Rain
|
|
"sleet": "🌨", # Sleet
|
|
"rain": "🌧", # Rain
|
|
"rain_showers": "🌦", # Rain Showers
|
|
"rain_showers_hi": "🌦", # Rain Showers (High)
|
|
"tsra": "⛈", # Thunderstorm
|
|
"tsra_sct": "⛈", # Scattered Thunderstorms
|
|
"tsra_hi": "⛈", # Thunderstorm (High)
|
|
"tornado": "🌪", # Tornado
|
|
"hurricane": "🌀", # Hurricane
|
|
"tropical_storm": "🌀", # Tropical Storm
|
|
"dust": "🌫", # Dust
|
|
"smoke": "🌫", # Smoke
|
|
"haze": "🌫", # Haze
|
|
"hot": "🥵", # Hot
|
|
"cold": "🥶", # Cold
|
|
"blizzard": "🌨", # Blizzard
|
|
"fog": "🌫", # Fog
|
|
}
|
|
|
|
DAY_BUBBLE_WIDTHS: ClassVar = {
|
|
"Monday": 235,
|
|
"Tuesday": 235,
|
|
"Wednesday": 260,
|
|
"Thursday": 245,
|
|
"Friday": 220,
|
|
"Saturday": 245,
|
|
"Sunday": 230,
|
|
}
|
|
|
|
|
|
@staticmethod
|
|
def get_weather_icon_code(icon_url: str) -> str:
|
|
"""Extract weather condition code from weather.gov icon URL.
|
|
|
|
Example: https://api.weather.gov/icons/land/day/tsra,40?size=medium
|
|
Returns: tsra
|
|
"""
|
|
if not icon_url:
|
|
return "skc"
|
|
|
|
# Extract the condition from the URL path
|
|
parts = icon_url.split("/")
|
|
if len(parts) >= 2:
|
|
# Get the last part and remove query parameters and comma-separated values
|
|
condition = parts[-1].split("?")[0].split(",")[0]
|
|
return condition
|
|
return "skc"
|
|
|
|
|
|
@classmethod
|
|
def get_weather_emoji(cls, icon_url: str) -> str:
|
|
"""Get emoji for weather condition from icon URL."""
|
|
code = cls.get_weather_icon_code(icon_url)
|
|
return cls.WEATHER_EMOJIS.get(code, "☀️")
|
|
|
|
|
|
@staticmethod
|
|
def fetch_weather(lat: float, lon: float) -> tuple[int, int, str]:
|
|
"""Fetch weather data from weather.gov API.
|
|
|
|
Returns:
|
|
tuple: (temperature_f, temperature_c, icon_url)
|
|
"""
|
|
# Weather.gov requires a User-Agent header
|
|
headers = {
|
|
"User-Agent": "(Weather SVG Generator, contact@example.com)"
|
|
}
|
|
|
|
# First, get the grid endpoint for this location
|
|
points_url = f"https://api.weather.gov/points/{lat},{lon}"
|
|
response = requests.get(points_url, headers=headers)
|
|
response.raise_for_status()
|
|
points_data = response.json()
|
|
|
|
# Get the forecast URL
|
|
forecast_url = points_data["properties"]["forecast"]
|
|
|
|
# Fetch the forecast
|
|
forecast_response = requests.get(forecast_url, headers=headers)
|
|
forecast_response.raise_for_status()
|
|
forecast_data = forecast_response.json()
|
|
|
|
# Get today's forecast (first period)
|
|
today_forecast = forecast_data["properties"]["periods"][0]
|
|
|
|
temp_f = today_forecast["temperature"]
|
|
temp_c = round((temp_f - 32) * 5 / 9)
|
|
icon_url = today_forecast["icon"]
|
|
|
|
return temp_f, temp_c, icon_url
|
|
|
|
|
|
def cli_cmd(self) -> None:
|
|
"""Generate the weather SVG file."""
|
|
try:
|
|
# Get current date info
|
|
today = pendulum.now()
|
|
today_day = today.format("dddd")
|
|
|
|
# Calculate time since graduation
|
|
grad_date = pendulum.parse(self.grad_date)
|
|
ps_time = pendulum.instance(today).diff_for_humans(grad_date, absolute=True)
|
|
|
|
# Fetch weather data
|
|
deg_f, deg_c, icon_url = self.fetch_weather(self.latitude, self.longitude)
|
|
weather_emoji = self.get_weather_emoji(icon_url)
|
|
|
|
# Read template
|
|
template_path = self.template_path
|
|
if not template_path.is_absolute():
|
|
template_path = Path(__file__).parent / template_path
|
|
|
|
template_content = template_path.read_text(encoding="utf-8")
|
|
|
|
# Replace placeholders
|
|
output_content = template_content.replace("{degF}", str(deg_f))
|
|
output_content = output_content.replace("{degC}", str(deg_c))
|
|
output_content = output_content.replace("{weatherEmoji}", weather_emoji)
|
|
output_content = output_content.replace("{psTime}", ps_time)
|
|
output_content = output_content.replace("{todayDay}", today_day)
|
|
output_content = output_content.replace(
|
|
"{dayBubbleWidth}",
|
|
str(self.DAY_BUBBLE_WIDTHS[today_day])
|
|
)
|
|
|
|
# Write output
|
|
output_path = self.output_path
|
|
if not output_path.is_absolute():
|
|
output_path = Path(__file__).parent / output_path
|
|
|
|
output_path.write_text(output_content, encoding="utf-8")
|
|
|
|
print(f"✅ Successfully generated {output_path}")
|
|
print(f"🌡️ Temperature: {deg_f}°F ({deg_c}°C)")
|
|
print(f"☁️ Condition: {weather_emoji}")
|
|
|
|
except requests.exceptions.RequestException as err:
|
|
print(f"❌ Error fetching weather data: {err}", file=sys.stderr)
|
|
sys.exit(1)
|
|
except FileNotFoundError as err:
|
|
print(f"❌ File not found: {err}", file=sys.stderr)
|
|
sys.exit(1)
|
|
except Exception as err:
|
|
print(f"❌ Unexpected error: {err}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
CliApp.run(App)
|