Replace em-dashes in user-facing copy across the server-rendered pages, static frontend views, and the strings the app injects at runtime, using colons, commas, parentheses or full stops as the context wants. Data placeholder glyphs (a lone "—" for a missing reading) are left alone, since a hyphen there reads as a minus sign in temperature columns. Also tighten the high-visibility surfaces (home hero and meta, about, privacy, city and records ledes, glossary blurbs) toward a plainer, more direct voice while keeping every factual claim intact. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
113 lines
4 KiB
Python
113 lines
4 KiB
Python
"""Discord delivery — currently just the daily "most unusual right now" post.
|
|
|
|
A once-daily message to a Discord channel via an **incoming webhook** (a single
|
|
secret URL; no bot, no gateway, no persistent process). It rides the notifier
|
|
daemon like the homepage feed refresh does, so it inherits the single-leader
|
|
invariant and costs no new infrastructure. Entirely optional: with no webhook
|
|
configured, everything here no-ops.
|
|
|
|
The message is built from the same `data/homepage.json` feed the homepage renders
|
|
(homepage.build/refresh), so the Discord post and the site always agree.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
import os
|
|
|
|
import httpx
|
|
|
|
import homepage
|
|
|
|
# The webhook URL IS the credential — env only, never the repo. Unset => disabled.
|
|
WEBHOOK_URL = os.environ.get("THERMOGRAPH_DISCORD_WEBHOOK", "").strip()
|
|
|
|
# How many cities the daily post lists.
|
|
POST_LIMIT = 8
|
|
|
|
# Embed accent: warm when the top anomaly is hot, cool when it's cold.
|
|
_HOT = 0xE8663B
|
|
_COLD = 0x4393C3
|
|
|
|
_TIMEOUT = httpx.Timeout(10.0)
|
|
|
|
|
|
def enabled() -> bool:
|
|
return bool(WEBHOOK_URL)
|
|
|
|
|
|
def _f_c(value) -> str:
|
|
"""A Fahrenheit reading as '104°F (40°C)' — Discord's audience is global, so
|
|
the post carries both, even though the site picks one per city."""
|
|
if value is None:
|
|
return "—"
|
|
f = round(value)
|
|
c = round((value - 32) * 5 / 9)
|
|
return f"{f}°F ({c}°C)"
|
|
|
|
|
|
def _line(card: dict) -> str:
|
|
"""One city's line in the post."""
|
|
display = card.get("display") or card.get("name") or "—"
|
|
metric = (card.get("metric_label") or "").lower() or "reading"
|
|
val = _f_c(card.get("value"))
|
|
normal = _f_c(card.get("normal")) if card.get("normal") is not None else None
|
|
pct = card.get("pct_label") or ""
|
|
grade = card.get("grade_label") or card.get("grade") or ""
|
|
tag = " · ".join(p for p in (pct, grade) if p)
|
|
when = f" ({card['date_label']})" if card.get("date_label") else ""
|
|
normal_txt = f", normal {normal}" if normal else ""
|
|
return f"**{display}**: {metric} {val}{normal_txt}{when}" + (f" · {tag}" if tag else "")
|
|
|
|
|
|
def build_embed(feed: dict) -> dict:
|
|
"""A Discord rich embed for the day's most unusual cities, or raise ValueError
|
|
if the feed has nothing to show."""
|
|
ranked = (feed or {}).get("ranked") or []
|
|
if not ranked:
|
|
raise ValueError("empty feed")
|
|
top = ranked[:POST_LIMIT]
|
|
color = _COLD if top[0].get("tail") == "cold" else _HOT
|
|
date_s = feed.get("date") or datetime.date.today().isoformat()
|
|
description = "\n".join(_line(c) for c in top)
|
|
embed = {
|
|
"title": "🌡️ Most unusual weather right now",
|
|
"description": description,
|
|
"url": "https://thermograph.org/",
|
|
"color": color,
|
|
"footer": {"text": "thermograph.org · every day graded against ~45 years of local history"},
|
|
}
|
|
# ISO8601 timestamp from the feed's build time, when available.
|
|
gen = feed.get("generated_at")
|
|
if gen:
|
|
embed["timestamp"] = datetime.datetime.fromtimestamp(
|
|
float(gen), tz=datetime.timezone.utc
|
|
).isoformat()
|
|
else:
|
|
embed["footer"]["text"] += f" · {date_s}"
|
|
return embed
|
|
|
|
|
|
def post_daily_feed(feed: dict | None = None) -> bool:
|
|
"""Post the daily feed to the configured webhook. Best-effort: returns True on
|
|
a 2xx, False otherwise (disabled, empty/stale feed, or any HTTP error) and
|
|
never raises — a Discord failure must not disturb the notifier pass."""
|
|
if not enabled():
|
|
return False
|
|
if feed is None:
|
|
feed = homepage.load()
|
|
if not feed or homepage.is_stale(feed):
|
|
return False
|
|
try:
|
|
embed = build_embed(feed)
|
|
except ValueError:
|
|
return False
|
|
payload = {
|
|
"username": "Thermograph",
|
|
"avatar_url": "https://thermograph.org/logo.png?v=3",
|
|
"embeds": [embed],
|
|
}
|
|
try:
|
|
resp = httpx.post(WEBHOOK_URL, json=payload, timeout=_TIMEOUT)
|
|
return 200 <= resp.status_code < 300
|
|
except Exception: # noqa: BLE001 - best-effort side channel
|
|
return False
|