Post the daily "most unusual right now" feed to Discord (#205)
Adds an optional daily Discord post of the day's most anomalous cities, built from
the same data/homepage.json feed the homepage renders — so the post and the site
always agree. Delivery is a single incoming-webhook POST (no bot, no gateway, no
new process): it rides the notifier daemon like the feed refresh does, once per
day after the refresh, leader-only. With no webhook configured it no-ops.
- backend/discord.py: build_embed() renders the top cities as a rich embed (each
line: city, reading in °F and °C, the normal for context, percentile + grade;
non-today readings are dated). post_daily_feed() is best-effort — it skips a
stale/empty feed and never raises, so a Discord failure can't disturb a pass.
- notify.py: a once-a-day guard (_maybe_post_discord) beside the homepage-refresh
guard; only marks the day done once a post lands, so a transient failure retries.
- deploy/thermograph.env.example: THERMOGRAPH_DISCORD_WEBHOOK (the URL is the
credential — env only).
The webhook value is the top anomaly's tail for the accent colour and reuses the
feed's build time as the embed timestamp. httpx (already a dependency) does the POST.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-20 00:48:22 +00:00
|
|
|
"""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 ""
|
Remove em-dashes from site copy and tighten the prose (#206)
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
2026-07-20 01:48:33 +00:00
|
|
|
return f"**{display}**: {metric} {val}{normal_txt}{when}" + (f" · {tag}" if tag else "")
|
Post the daily "most unusual right now" feed to Discord (#205)
Adds an optional daily Discord post of the day's most anomalous cities, built from
the same data/homepage.json feed the homepage renders — so the post and the site
always agree. Delivery is a single incoming-webhook POST (no bot, no gateway, no
new process): it rides the notifier daemon like the feed refresh does, once per
day after the refresh, leader-only. With no webhook configured it no-ops.
- backend/discord.py: build_embed() renders the top cities as a rich embed (each
line: city, reading in °F and °C, the normal for context, percentile + grade;
non-today readings are dated). post_daily_feed() is best-effort — it skips a
stale/empty feed and never raises, so a Discord failure can't disturb a pass.
- notify.py: a once-a-day guard (_maybe_post_discord) beside the homepage-refresh
guard; only marks the day done once a post lands, so a transient failure retries.
- deploy/thermograph.env.example: THERMOGRAPH_DISCORD_WEBHOOK (the URL is the
credential — env only).
The webhook value is the top anomaly's tail for the accent colour and reuses the
feed's build time as the embed timestamp. httpx (already a dependency) does the POST.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-20 00:48:22 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|