SEO: crawlable programmatic climate pages + technical hygiene (#96)
* SEO: generate curated city set for crawlable climate pages
gen_cities.py reuses the GeoNames index places.py already parses to select the top
~500 metros by population, assigns each a stable URL-safe slug (dropping admin1 when
it repeats the city name), and writes committed backend/cities.json. cities.py loads
it lazily with slug lookup, all_slugs(), display_name(), and by_country() grouping
for the upcoming hub + sitemap.
* SEO: rendering core, robots.txt, sitemap.xml, and metadata hygiene
- content.py: Jinja2 environment + HTML responder (ETag/304), dynamic /robots.txt
(disallows /api and /alerts, points at the sitemap) and /sitemap.xml (enumerates
the home/static pages plus every city, month, and records URL from cities.py).
Registered on the app before the StaticFiles mount so the routes win.
- templates/base.html.j2: shared layout with unique title/description, self-
referential canonical, Open Graph, favicon/manifest, header nav (adds a Climate
link) and a footer link graph.
- Give each existing page a unique <meta description> (were 5x identical) and a
self-referential <link rel=canonical>; add WebApplication JSON-LD to the home page.
- Pin jinja2.
* SEO: server-rendered per-city climate page (/climate/{slug})
The keystone crawlable page: for a city it snaps to the grid cell, loads the
archive (fetching once if missing, self-healing), and renders as real HTML — a
'how today compares' block (grade + percentile per metric from grade_day, tinted
by tier), a monthly normals table (climatology at each month's 15th, shown in °F
and °C), all-time records (new grading.all_time_records helper), a breadcrumb,
Dataset+Place+BreadcrumbList JSON-LD, self-referential canonical, and links into
the interactive tool + month/records pages. Content-page CSS added to style.css
(renamed the table class to avoid colliding with the app's .normals flex row).
* SEO: month (/climate/{slug}/{month}) and records (/climate/{slug}/records) pages
Month pages render the exact-month long-tail ('average weather in {city} in
{month}') with that month's average high/low, typical p10-p90 range, month-specific
records, and prev/next month links. Records pages show all-time record highs/lows
per metric with dates (grading.all_time_records). Shared _resolve_city helper; the
literal /records route is registered before the {month} param and month names are
validated (unknown month -> 404).
* SEO: climate hub, weather glossary, and about/methodology pages
- /climate: crawlable directory of all ~500 cities grouped by country — the
internal-link graph that lets search engines discover every city page.
- /glossary + /glossary/{term}: plain-language definitions (climate normal,
percentile, temperature anomaly, feels-like, heat index, wind chill, humidity,
reanalysis) with DefinedTerm JSON-LD and cross-links into the tool.
- /about: methodology page (ERA5 data source, 45-year baseline, +/-7-day window,
percentile grading) for E-E-A-T. All linked from the shared footer.
* SEO: archive warmer, content-page tests, and deploy docs
- warm_cities.py: paced, idempotent offline warmer that pre-fetches each city
cell's archive so /climate pages serve from cache and a crawl can't burst the
archive quota (pages self-heal if hit before warming).
- tests/test_content.py: city-set slug uniqueness/lookup, robots.txt, sitemap
enumerating city/month/records URLs, and that a rendered city page carries the
stats + canonical + Dataset JSON-LD in the HTML; plus month/records/hub/glossary/
about routing and 404s.
- DEPLOY.md: document the content pages, the warm step, and submitting the sitemap.
2026-07-15 23:53:11 +00:00
|
|
|
"""Pre-warm the archives for the curated city set (backend/cities.json) so the
|
|
|
|
|
crawlable /climate pages render from cache and a search-engine crawl never bursts
|
|
|
|
|
the archive API quota. Run at/after deploy:
|
|
|
|
|
|
|
|
|
|
python warm_cities.py [--limit N] [--pace SECONDS]
|
|
|
|
|
|
|
|
|
|
Idempotent: a cell whose archive is already cached is skipped. Fetches are paced
|
|
|
|
|
(default 2s) to stay well under the archive API's rate limit. A cell that still
|
|
|
|
|
has no cached archive when its page is first requested self-heals via get_history,
|
|
|
|
|
so this is an optimization, not a hard dependency.
|
2026-07-23 04:41:26 +00:00
|
|
|
|
|
|
|
|
Deploy launches this script detached on every deploy with no overlap check, so a
|
|
|
|
|
slow previous run (still mid-warm) can still be going when the next deploy's
|
|
|
|
|
invocation starts. Two overlapping runs would double-spend archive-fetch quota on
|
|
|
|
|
the same cells and race climate._write_cache's parquet writes, so the CLI entry
|
|
|
|
|
point below claims a single-instance flock (see LOCK_PATH) before calling main().
|
SEO: crawlable programmatic climate pages + technical hygiene (#96)
* SEO: generate curated city set for crawlable climate pages
gen_cities.py reuses the GeoNames index places.py already parses to select the top
~500 metros by population, assigns each a stable URL-safe slug (dropping admin1 when
it repeats the city name), and writes committed backend/cities.json. cities.py loads
it lazily with slug lookup, all_slugs(), display_name(), and by_country() grouping
for the upcoming hub + sitemap.
* SEO: rendering core, robots.txt, sitemap.xml, and metadata hygiene
- content.py: Jinja2 environment + HTML responder (ETag/304), dynamic /robots.txt
(disallows /api and /alerts, points at the sitemap) and /sitemap.xml (enumerates
the home/static pages plus every city, month, and records URL from cities.py).
Registered on the app before the StaticFiles mount so the routes win.
- templates/base.html.j2: shared layout with unique title/description, self-
referential canonical, Open Graph, favicon/manifest, header nav (adds a Climate
link) and a footer link graph.
- Give each existing page a unique <meta description> (were 5x identical) and a
self-referential <link rel=canonical>; add WebApplication JSON-LD to the home page.
- Pin jinja2.
* SEO: server-rendered per-city climate page (/climate/{slug})
The keystone crawlable page: for a city it snaps to the grid cell, loads the
archive (fetching once if missing, self-healing), and renders as real HTML — a
'how today compares' block (grade + percentile per metric from grade_day, tinted
by tier), a monthly normals table (climatology at each month's 15th, shown in °F
and °C), all-time records (new grading.all_time_records helper), a breadcrumb,
Dataset+Place+BreadcrumbList JSON-LD, self-referential canonical, and links into
the interactive tool + month/records pages. Content-page CSS added to style.css
(renamed the table class to avoid colliding with the app's .normals flex row).
* SEO: month (/climate/{slug}/{month}) and records (/climate/{slug}/records) pages
Month pages render the exact-month long-tail ('average weather in {city} in
{month}') with that month's average high/low, typical p10-p90 range, month-specific
records, and prev/next month links. Records pages show all-time record highs/lows
per metric with dates (grading.all_time_records). Shared _resolve_city helper; the
literal /records route is registered before the {month} param and month names are
validated (unknown month -> 404).
* SEO: climate hub, weather glossary, and about/methodology pages
- /climate: crawlable directory of all ~500 cities grouped by country — the
internal-link graph that lets search engines discover every city page.
- /glossary + /glossary/{term}: plain-language definitions (climate normal,
percentile, temperature anomaly, feels-like, heat index, wind chill, humidity,
reanalysis) with DefinedTerm JSON-LD and cross-links into the tool.
- /about: methodology page (ERA5 data source, 45-year baseline, +/-7-day window,
percentile grading) for E-E-A-T. All linked from the shared footer.
* SEO: archive warmer, content-page tests, and deploy docs
- warm_cities.py: paced, idempotent offline warmer that pre-fetches each city
cell's archive so /climate pages serve from cache and a crawl can't burst the
archive quota (pages self-heal if hit before warming).
- tests/test_content.py: city-set slug uniqueness/lookup, robots.txt, sitemap
enumerating city/month/records URLs, and that a rendered city page carries the
stats + canonical + Dataset JSON-LD in the HTML; plus month/records/hub/glossary/
about routing and 404s.
- DEPLOY.md: document the content pages, the warm step, and submitting the sitemap.
2026-07-15 23:53:11 +00:00
|
|
|
"""
|
2026-07-23 04:41:26 +00:00
|
|
|
import os
|
SEO: crawlable programmatic climate pages + technical hygiene (#96)
* SEO: generate curated city set for crawlable climate pages
gen_cities.py reuses the GeoNames index places.py already parses to select the top
~500 metros by population, assigns each a stable URL-safe slug (dropping admin1 when
it repeats the city name), and writes committed backend/cities.json. cities.py loads
it lazily with slug lookup, all_slugs(), display_name(), and by_country() grouping
for the upcoming hub + sitemap.
* SEO: rendering core, robots.txt, sitemap.xml, and metadata hygiene
- content.py: Jinja2 environment + HTML responder (ETag/304), dynamic /robots.txt
(disallows /api and /alerts, points at the sitemap) and /sitemap.xml (enumerates
the home/static pages plus every city, month, and records URL from cities.py).
Registered on the app before the StaticFiles mount so the routes win.
- templates/base.html.j2: shared layout with unique title/description, self-
referential canonical, Open Graph, favicon/manifest, header nav (adds a Climate
link) and a footer link graph.
- Give each existing page a unique <meta description> (were 5x identical) and a
self-referential <link rel=canonical>; add WebApplication JSON-LD to the home page.
- Pin jinja2.
* SEO: server-rendered per-city climate page (/climate/{slug})
The keystone crawlable page: for a city it snaps to the grid cell, loads the
archive (fetching once if missing, self-healing), and renders as real HTML — a
'how today compares' block (grade + percentile per metric from grade_day, tinted
by tier), a monthly normals table (climatology at each month's 15th, shown in °F
and °C), all-time records (new grading.all_time_records helper), a breadcrumb,
Dataset+Place+BreadcrumbList JSON-LD, self-referential canonical, and links into
the interactive tool + month/records pages. Content-page CSS added to style.css
(renamed the table class to avoid colliding with the app's .normals flex row).
* SEO: month (/climate/{slug}/{month}) and records (/climate/{slug}/records) pages
Month pages render the exact-month long-tail ('average weather in {city} in
{month}') with that month's average high/low, typical p10-p90 range, month-specific
records, and prev/next month links. Records pages show all-time record highs/lows
per metric with dates (grading.all_time_records). Shared _resolve_city helper; the
literal /records route is registered before the {month} param and month names are
validated (unknown month -> 404).
* SEO: climate hub, weather glossary, and about/methodology pages
- /climate: crawlable directory of all ~500 cities grouped by country — the
internal-link graph that lets search engines discover every city page.
- /glossary + /glossary/{term}: plain-language definitions (climate normal,
percentile, temperature anomaly, feels-like, heat index, wind chill, humidity,
reanalysis) with DefinedTerm JSON-LD and cross-links into the tool.
- /about: methodology page (ERA5 data source, 45-year baseline, +/-7-day window,
percentile grading) for E-E-A-T. All linked from the shared footer.
* SEO: archive warmer, content-page tests, and deploy docs
- warm_cities.py: paced, idempotent offline warmer that pre-fetches each city
cell's archive so /climate pages serve from cache and a crawl can't burst the
archive quota (pages self-heal if hit before warming).
- tests/test_content.py: city-set slug uniqueness/lookup, robots.txt, sitemap
enumerating city/month/records URLs, and that a rendered city page carries the
stats + canonical + Dataset JSON-LD in the HTML; plus month/records/hub/glossary/
about routing and 404s.
- DEPLOY.md: document the content pages, the warm step, and submitting the sitemap.
2026-07-15 23:53:11 +00:00
|
|
|
import sys
|
|
|
|
|
import time
|
|
|
|
|
|
2026-07-24 19:31:08 +00:00
|
|
|
from api import content_payloads
|
2026-07-21 16:09:35 +00:00
|
|
|
from api import homepage
|
2026-07-24 19:31:08 +00:00
|
|
|
from api import payloads as cache_ids
|
2026-07-23 04:41:26 +00:00
|
|
|
from core import singleton
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
from data import cities
|
|
|
|
|
from data import climate
|
|
|
|
|
from data import grid
|
2026-07-24 19:31:08 +00:00
|
|
|
from data import store
|
2026-07-23 04:41:26 +00:00
|
|
|
import paths
|
|
|
|
|
|
2026-07-24 19:31:08 +00:00
|
|
|
# The deployment base path, derived exactly as api/content_routes.py does so the
|
|
|
|
|
# breadcrumb hrefs / jsonld URLs the warmer bakes match what a live request bakes.
|
|
|
|
|
_BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
|
|
|
|
|
BASE = f"/{_BASE}" if _BASE else ""
|
|
|
|
|
|
2026-07-23 04:41:26 +00:00
|
|
|
# core/singleton.py's flock guard, reused here for cross-*process* (not
|
|
|
|
|
# cross-worker) exclusion: the first invocation holds this for its process
|
|
|
|
|
# lifetime; a second one (an overlapping deploy) fails the non-blocking flock
|
|
|
|
|
# and stands down immediately.
|
|
|
|
|
LOCK_PATH = os.path.join(paths.DATA_DIR, "warm_cities.lock")
|
SEO: crawlable programmatic climate pages + technical hygiene (#96)
* SEO: generate curated city set for crawlable climate pages
gen_cities.py reuses the GeoNames index places.py already parses to select the top
~500 metros by population, assigns each a stable URL-safe slug (dropping admin1 when
it repeats the city name), and writes committed backend/cities.json. cities.py loads
it lazily with slug lookup, all_slugs(), display_name(), and by_country() grouping
for the upcoming hub + sitemap.
* SEO: rendering core, robots.txt, sitemap.xml, and metadata hygiene
- content.py: Jinja2 environment + HTML responder (ETag/304), dynamic /robots.txt
(disallows /api and /alerts, points at the sitemap) and /sitemap.xml (enumerates
the home/static pages plus every city, month, and records URL from cities.py).
Registered on the app before the StaticFiles mount so the routes win.
- templates/base.html.j2: shared layout with unique title/description, self-
referential canonical, Open Graph, favicon/manifest, header nav (adds a Climate
link) and a footer link graph.
- Give each existing page a unique <meta description> (were 5x identical) and a
self-referential <link rel=canonical>; add WebApplication JSON-LD to the home page.
- Pin jinja2.
* SEO: server-rendered per-city climate page (/climate/{slug})
The keystone crawlable page: for a city it snaps to the grid cell, loads the
archive (fetching once if missing, self-healing), and renders as real HTML — a
'how today compares' block (grade + percentile per metric from grade_day, tinted
by tier), a monthly normals table (climatology at each month's 15th, shown in °F
and °C), all-time records (new grading.all_time_records helper), a breadcrumb,
Dataset+Place+BreadcrumbList JSON-LD, self-referential canonical, and links into
the interactive tool + month/records pages. Content-page CSS added to style.css
(renamed the table class to avoid colliding with the app's .normals flex row).
* SEO: month (/climate/{slug}/{month}) and records (/climate/{slug}/records) pages
Month pages render the exact-month long-tail ('average weather in {city} in
{month}') with that month's average high/low, typical p10-p90 range, month-specific
records, and prev/next month links. Records pages show all-time record highs/lows
per metric with dates (grading.all_time_records). Shared _resolve_city helper; the
literal /records route is registered before the {month} param and month names are
validated (unknown month -> 404).
* SEO: climate hub, weather glossary, and about/methodology pages
- /climate: crawlable directory of all ~500 cities grouped by country — the
internal-link graph that lets search engines discover every city page.
- /glossary + /glossary/{term}: plain-language definitions (climate normal,
percentile, temperature anomaly, feels-like, heat index, wind chill, humidity,
reanalysis) with DefinedTerm JSON-LD and cross-links into the tool.
- /about: methodology page (ERA5 data source, 45-year baseline, +/-7-day window,
percentile grading) for E-E-A-T. All linked from the shared footer.
* SEO: archive warmer, content-page tests, and deploy docs
- warm_cities.py: paced, idempotent offline warmer that pre-fetches each city
cell's archive so /climate pages serve from cache and a crawl can't burst the
archive quota (pages self-heal if hit before warming).
- tests/test_content.py: city-set slug uniqueness/lookup, robots.txt, sitemap
enumerating city/month/records URLs, and that a rendered city page carries the
stats + canonical + Dataset JSON-LD in the HTML; plus month/records/hub/glossary/
about routing and 404s.
- DEPLOY.md: document the content pages, the warm step, and submitting the sitemap.
2026-07-15 23:53:11 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def main(limit: int | None = None, pace: float = 2.0) -> None:
|
|
|
|
|
todo = cities.all_cities()
|
|
|
|
|
if limit:
|
|
|
|
|
todo = todo[:limit]
|
|
|
|
|
fetched = skipped = failed = 0
|
|
|
|
|
for i, c in enumerate(todo, 1):
|
|
|
|
|
cell = grid.snap(c["lat"], c["lon"])
|
|
|
|
|
cached = climate.load_cached_history(cell)
|
|
|
|
|
if cached is not None and not cached.is_empty():
|
|
|
|
|
skipped += 1
|
|
|
|
|
continue
|
|
|
|
|
try:
|
|
|
|
|
climate.get_history(cell) # fetch + cache the ~45-yr archive
|
|
|
|
|
climate.get_recent_forecast(cell) # + the recent/forecast bundle (today block)
|
|
|
|
|
fetched += 1
|
|
|
|
|
print(f"[{i}/{len(todo)}] warmed {c['slug']} ({cell['id']})")
|
|
|
|
|
time.sleep(pace)
|
|
|
|
|
except Exception as e: # noqa: BLE001 - keep going; the page self-heals later
|
|
|
|
|
failed += 1
|
|
|
|
|
print(f"[{i}/{len(todo)}] FAILED {c['slug']}: {e}")
|
|
|
|
|
time.sleep(pace)
|
|
|
|
|
print(f"done: fetched={fetched} skipped(cached)={skipped} failed={failed}")
|
|
|
|
|
|
Rebuild the homepage as a distribution landing; add an SMTP seam (#178)
The homepage was a bare tool: a find-bar and an empty panel reading "Find a
location to begin." A visitor arriving from a search result or a shared link
learned nothing about what the product does before deciding to leave.
Rebuild it around the Weekly view, which is untouched:
- Hero with the question as the h1, and a grade card showing a real graded
example — the most unusual city we're currently tracking, either tail. The
card's frame and text slots are server-rendered with reserved heights, so
app.js re-pointing it at the visitor's own place shifts nothing.
- "Unusual right now" strip, CSS scroll-snap, no JS carousel. A cold-tail city
is force-included whenever one qualifies.
- Stance line, how-it-works, explore cards, and 12 city chips linking into the
~1000-page /climate surface.
- Monthly digest form, in the footer of every page.
Serve / from Jinja instead of a static file with placeholder substitution, so
crawlers and no-JS readers get the whole page as real HTML. home.html.j2
extends base.html.j2 and carries app.js's DOM contract over verbatim; the brand
degrades to a <p> so the hero owns the sole h1. frontend/index.html is deleted
rather than left behind the static mount, where it would keep serving indexable
duplicate content.
"Where is it most unusual right now" has no cheap answer at request time —
percentiles live inside zlib-compressed payload blobs with no column to sort on.
So homepage.py sweeps the warm cache and writes data/homepage.json, read by the
template. The sweep is strictly cache-only (climate.load_cached_recent_forecast
is new, the sibling of load_cached_history), so grading ~1000 cities costs zero
upstream requests. It rides the notifier's timer behind an hourly guard rather
than starting a second daemon, and also runs at the tail of warm_cities so a
fresh deploy has a populated feed.
Instrumentation: metrics gains a product-event counter keyed by (event,
referrer domain, UTC day), behind an allowlist and a per-IP rate limit, fed by
POST /api/v2/event. The referrer is taken from the request's own header, never
from the client. The beacon gets its own inbound category that record_inbound
ignores, so reporting an interaction doesn't also count as traffic. The
dashboard grows an events block with per-referrer attribution.
Email is scaffolded but sends nothing yet. mailer.py talks stdlib smtplib to a
local Postfix null client on 127.0.0.1:25 (deploy/provision-mail.sh), so the
choice between direct-to-MX and relaying through a provider stays a Postfix
config change with no code change. The backend defaults to "console", which
logs and sends nothing, so dev and tests exercise the whole signup path safely.
Signups land in pending_digest unconfirmed; collecting the list shouldn't wait
on delivery.
Also adds /privacy, linked from the footer and kept out of the sitemap.
The strip's classes are named unusual-* rather than record-*: .record-card is
already the SEO records page's, and reusing it leaked layout rules onto
/climate/<slug>/records.
2026-07-18 07:39:47 +00:00
|
|
|
# Rebuild the homepage's "unusual right now" feed from whatever is now warm.
|
|
|
|
|
# Runs here as well as on the notifier loop so a fresh deploy has a populated
|
|
|
|
|
# feed immediately, and so it still refreshes when the notifier is disabled.
|
|
|
|
|
try:
|
|
|
|
|
feed = homepage.refresh()
|
|
|
|
|
print(f"homepage feed: {feed['considered']} cities graded, "
|
|
|
|
|
f"{len(feed['ranked'])} ranked")
|
|
|
|
|
except Exception as e: # noqa: BLE001 - the homepage renders without the feed
|
|
|
|
|
print(f"homepage feed: FAILED {e}")
|
|
|
|
|
|
SEO: crawlable programmatic climate pages + technical hygiene (#96)
* SEO: generate curated city set for crawlable climate pages
gen_cities.py reuses the GeoNames index places.py already parses to select the top
~500 metros by population, assigns each a stable URL-safe slug (dropping admin1 when
it repeats the city name), and writes committed backend/cities.json. cities.py loads
it lazily with slug lookup, all_slugs(), display_name(), and by_country() grouping
for the upcoming hub + sitemap.
* SEO: rendering core, robots.txt, sitemap.xml, and metadata hygiene
- content.py: Jinja2 environment + HTML responder (ETag/304), dynamic /robots.txt
(disallows /api and /alerts, points at the sitemap) and /sitemap.xml (enumerates
the home/static pages plus every city, month, and records URL from cities.py).
Registered on the app before the StaticFiles mount so the routes win.
- templates/base.html.j2: shared layout with unique title/description, self-
referential canonical, Open Graph, favicon/manifest, header nav (adds a Climate
link) and a footer link graph.
- Give each existing page a unique <meta description> (were 5x identical) and a
self-referential <link rel=canonical>; add WebApplication JSON-LD to the home page.
- Pin jinja2.
* SEO: server-rendered per-city climate page (/climate/{slug})
The keystone crawlable page: for a city it snaps to the grid cell, loads the
archive (fetching once if missing, self-healing), and renders as real HTML — a
'how today compares' block (grade + percentile per metric from grade_day, tinted
by tier), a monthly normals table (climatology at each month's 15th, shown in °F
and °C), all-time records (new grading.all_time_records helper), a breadcrumb,
Dataset+Place+BreadcrumbList JSON-LD, self-referential canonical, and links into
the interactive tool + month/records pages. Content-page CSS added to style.css
(renamed the table class to avoid colliding with the app's .normals flex row).
* SEO: month (/climate/{slug}/{month}) and records (/climate/{slug}/records) pages
Month pages render the exact-month long-tail ('average weather in {city} in
{month}') with that month's average high/low, typical p10-p90 range, month-specific
records, and prev/next month links. Records pages show all-time record highs/lows
per metric with dates (grading.all_time_records). Shared _resolve_city helper; the
literal /records route is registered before the {month} param and month names are
validated (unknown month -> 404).
* SEO: climate hub, weather glossary, and about/methodology pages
- /climate: crawlable directory of all ~500 cities grouped by country — the
internal-link graph that lets search engines discover every city page.
- /glossary + /glossary/{term}: plain-language definitions (climate normal,
percentile, temperature anomaly, feels-like, heat index, wind chill, humidity,
reanalysis) with DefinedTerm JSON-LD and cross-links into the tool.
- /about: methodology page (ERA5 data source, 45-year baseline, +/-7-day window,
percentile grading) for E-E-A-T. All linked from the shared footer.
* SEO: archive warmer, content-page tests, and deploy docs
- warm_cities.py: paced, idempotent offline warmer that pre-fetches each city
cell's archive so /climate pages serve from cache and a crawl can't burst the
archive quota (pages self-heal if hit before warming).
- tests/test_content.py: city-set slug uniqueness/lookup, robots.txt, sitemap
enumerating city/month/records URLs, and that a rendered city page carries the
stats + canonical + Dataset JSON-LD in the HTML; plus month/records/hub/glossary/
about routing and 404s.
- DEPLOY.md: document the content pages, the warm step, and submitting the sitemap.
2026-07-15 23:53:11 +00:00
|
|
|
|
2026-07-24 19:31:08 +00:00
|
|
|
# --- content derived-store pre-warm -------------------------------------------
|
|
|
|
|
# The SEO /climate/<city>[/month|/records] pages render their JSON from the
|
|
|
|
|
# derived store (api/content_routes.py), keyed by the cheap+stable content token
|
|
|
|
|
# (payloads.content_token -> PAYLOAD_VER:CONTENT_VER:archive_last_date). That token
|
|
|
|
|
# turns over only when a cell's archive gains a new day (~1x/day), so on the first
|
|
|
|
|
# request after each daily advance the page would otherwise recompute a 45-year
|
|
|
|
|
# payload cold. Pre-warming rebuilds those rows off-request — cache-only, so it
|
|
|
|
|
# never spends upstream quota — so users and crawlers land on a warm cache.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _content_specs(slug: str, origin: str) -> "list[tuple[str, str, str, int | None]]":
|
|
|
|
|
"""The (store-kind, store-key, builder-tag, month_idx) tuples for one city,
|
|
|
|
|
matching api/content_routes.py's kinds/keys exactly: content-city and
|
|
|
|
|
content-records key on ``{slug}:{origin}`` (the origin is folded in because the
|
|
|
|
|
payloads' jsonld.url is origin-qualified), content-month keys on
|
|
|
|
|
``{slug}:{month}`` for each of the 12 months."""
|
|
|
|
|
specs = [
|
|
|
|
|
("content-city", f"{slug}:{origin}", "city", None),
|
|
|
|
|
("content-records", f"{slug}:{origin}", "records", None),
|
|
|
|
|
]
|
|
|
|
|
for month, idx in content_payloads.MONTH_INDEX.items():
|
|
|
|
|
specs.append(("content-month", f"{slug}:{month}", "month", idx))
|
|
|
|
|
return specs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_content(tag: str, city: dict, history, recent, origin: str, month_idx: "int | None") -> dict:
|
|
|
|
|
if tag == "city":
|
|
|
|
|
return content_payloads.city_payload(origin, BASE, city, history, recent)
|
|
|
|
|
if tag == "records":
|
|
|
|
|
return content_payloads.records_payload(origin, BASE, city, history)
|
|
|
|
|
return content_payloads.month_payload(BASE, city, history, month_idx)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def warm_content(limit: int | None = None, origin: str | None = None, pace: float = 0.05) -> dict:
|
|
|
|
|
"""Rebuild any missing/stale content-page payloads in the derived store.
|
|
|
|
|
|
|
|
|
|
For each curated city, compute the current content token (cheap — no history
|
|
|
|
|
load) and check whether every content kind (city, 12 months, records) already
|
|
|
|
|
has a row for that token. Cities that are wholly fresh are skipped *without*
|
|
|
|
|
loading the archive, so a re-run after everything is warm is near-instant and
|
|
|
|
|
only cells whose archive genuinely advanced (new token) do work — the same
|
|
|
|
|
idempotent contract as ``main``.
|
|
|
|
|
|
|
|
|
|
Cache-only, exactly like ``main``: the history and recent/forecast bundles are
|
|
|
|
|
read from the cache; a cell with no cached archive is skipped rather than
|
|
|
|
|
fetched, so warming never spends a single upstream request.
|
|
|
|
|
|
|
|
|
|
``limit`` caps the number of cities actually (re)built this call (not scanned),
|
|
|
|
|
so a caller can bound one pass's wall-clock and rely on the idempotent skip to
|
|
|
|
|
resume from where it left off on the next call. Returns a small counts dict.
|
|
|
|
|
"""
|
|
|
|
|
if origin is None:
|
|
|
|
|
origin = os.environ.get("THERMOGRAPH_BASE_URL", "https://thermograph.org").rstrip("/")
|
|
|
|
|
built = warmed = skipped = empty = failed = 0
|
|
|
|
|
for c in cities.all_cities():
|
|
|
|
|
if limit is not None and warmed >= limit:
|
|
|
|
|
break
|
|
|
|
|
slug = c["slug"]
|
|
|
|
|
cell = grid.snap(c["lat"], c["lon"])
|
|
|
|
|
cell_id = cell["id"]
|
|
|
|
|
token = cache_ids.content_token(cell_id)
|
|
|
|
|
specs = _content_specs(slug, origin)
|
|
|
|
|
# Cheap idempotency gate: which kinds are missing/stale for this token?
|
|
|
|
|
# get_payload is a cheap keyed lookup; loading the 45-year archive is not,
|
|
|
|
|
# so we defer that until we know at least one kind actually needs building.
|
|
|
|
|
stale = [s for s in specs if store.get_payload(s[0], cell_id, s[1], token) is None]
|
|
|
|
|
if not stale:
|
|
|
|
|
skipped += 1
|
|
|
|
|
continue
|
|
|
|
|
history = climate.load_cached_history(cell)
|
|
|
|
|
if history is None or history.is_empty():
|
|
|
|
|
# No cached archive yet -> nothing to build from, and warming must not
|
|
|
|
|
# fetch upstream. The cell self-heals on its first live request.
|
|
|
|
|
empty += 1
|
|
|
|
|
continue
|
|
|
|
|
# today_vs_normal on the city payload needs the recent/forecast bundle;
|
|
|
|
|
# read it cache-only (never fetch) — None just drops that one block.
|
|
|
|
|
recent = climate.load_cached_recent_forecast(cell)
|
|
|
|
|
did_work = False
|
|
|
|
|
for kind, key, tag, month_idx in stale:
|
|
|
|
|
try:
|
|
|
|
|
payload = _build_content(tag, c, history, recent, origin, month_idx)
|
|
|
|
|
store.put_payload(kind, cell_id, key, token, payload)
|
|
|
|
|
built += 1
|
|
|
|
|
did_work = True
|
|
|
|
|
except Exception: # noqa: BLE001 - one bad payload must not abort the sweep
|
|
|
|
|
failed += 1
|
|
|
|
|
if did_work:
|
|
|
|
|
warmed += 1
|
|
|
|
|
if pace:
|
|
|
|
|
time.sleep(pace)
|
|
|
|
|
return {"warmed": warmed, "built": built, "skipped": skipped, "empty": empty, "failed": failed}
|
|
|
|
|
|
|
|
|
|
|
SEO: crawlable programmatic climate pages + technical hygiene (#96)
* SEO: generate curated city set for crawlable climate pages
gen_cities.py reuses the GeoNames index places.py already parses to select the top
~500 metros by population, assigns each a stable URL-safe slug (dropping admin1 when
it repeats the city name), and writes committed backend/cities.json. cities.py loads
it lazily with slug lookup, all_slugs(), display_name(), and by_country() grouping
for the upcoming hub + sitemap.
* SEO: rendering core, robots.txt, sitemap.xml, and metadata hygiene
- content.py: Jinja2 environment + HTML responder (ETag/304), dynamic /robots.txt
(disallows /api and /alerts, points at the sitemap) and /sitemap.xml (enumerates
the home/static pages plus every city, month, and records URL from cities.py).
Registered on the app before the StaticFiles mount so the routes win.
- templates/base.html.j2: shared layout with unique title/description, self-
referential canonical, Open Graph, favicon/manifest, header nav (adds a Climate
link) and a footer link graph.
- Give each existing page a unique <meta description> (were 5x identical) and a
self-referential <link rel=canonical>; add WebApplication JSON-LD to the home page.
- Pin jinja2.
* SEO: server-rendered per-city climate page (/climate/{slug})
The keystone crawlable page: for a city it snaps to the grid cell, loads the
archive (fetching once if missing, self-healing), and renders as real HTML — a
'how today compares' block (grade + percentile per metric from grade_day, tinted
by tier), a monthly normals table (climatology at each month's 15th, shown in °F
and °C), all-time records (new grading.all_time_records helper), a breadcrumb,
Dataset+Place+BreadcrumbList JSON-LD, self-referential canonical, and links into
the interactive tool + month/records pages. Content-page CSS added to style.css
(renamed the table class to avoid colliding with the app's .normals flex row).
* SEO: month (/climate/{slug}/{month}) and records (/climate/{slug}/records) pages
Month pages render the exact-month long-tail ('average weather in {city} in
{month}') with that month's average high/low, typical p10-p90 range, month-specific
records, and prev/next month links. Records pages show all-time record highs/lows
per metric with dates (grading.all_time_records). Shared _resolve_city helper; the
literal /records route is registered before the {month} param and month names are
validated (unknown month -> 404).
* SEO: climate hub, weather glossary, and about/methodology pages
- /climate: crawlable directory of all ~500 cities grouped by country — the
internal-link graph that lets search engines discover every city page.
- /glossary + /glossary/{term}: plain-language definitions (climate normal,
percentile, temperature anomaly, feels-like, heat index, wind chill, humidity,
reanalysis) with DefinedTerm JSON-LD and cross-links into the tool.
- /about: methodology page (ERA5 data source, 45-year baseline, +/-7-day window,
percentile grading) for E-E-A-T. All linked from the shared footer.
* SEO: archive warmer, content-page tests, and deploy docs
- warm_cities.py: paced, idempotent offline warmer that pre-fetches each city
cell's archive so /climate pages serve from cache and a crawl can't burst the
archive quota (pages self-heal if hit before warming).
- tests/test_content.py: city-set slug uniqueness/lookup, robots.txt, sitemap
enumerating city/month/records URLs, and that a rendered city page carries the
stats + canonical + Dataset JSON-LD in the HTML; plus month/records/hub/glossary/
about routing and 404s.
- DEPLOY.md: document the content pages, the warm step, and submitting the sitemap.
2026-07-15 23:53:11 +00:00
|
|
|
if __name__ == "__main__":
|
2026-07-23 04:41:26 +00:00
|
|
|
if not singleton.claim(LOCK_PATH):
|
|
|
|
|
# Not an error: an overlapping run just means a previous deploy's warm is
|
|
|
|
|
# still in flight. Exit 0 so the deploy script doesn't treat this as a
|
|
|
|
|
# failure.
|
|
|
|
|
print("warm_cities: another instance is already running -- exiting")
|
|
|
|
|
sys.exit(0)
|
SEO: crawlable programmatic climate pages + technical hygiene (#96)
* SEO: generate curated city set for crawlable climate pages
gen_cities.py reuses the GeoNames index places.py already parses to select the top
~500 metros by population, assigns each a stable URL-safe slug (dropping admin1 when
it repeats the city name), and writes committed backend/cities.json. cities.py loads
it lazily with slug lookup, all_slugs(), display_name(), and by_country() grouping
for the upcoming hub + sitemap.
* SEO: rendering core, robots.txt, sitemap.xml, and metadata hygiene
- content.py: Jinja2 environment + HTML responder (ETag/304), dynamic /robots.txt
(disallows /api and /alerts, points at the sitemap) and /sitemap.xml (enumerates
the home/static pages plus every city, month, and records URL from cities.py).
Registered on the app before the StaticFiles mount so the routes win.
- templates/base.html.j2: shared layout with unique title/description, self-
referential canonical, Open Graph, favicon/manifest, header nav (adds a Climate
link) and a footer link graph.
- Give each existing page a unique <meta description> (were 5x identical) and a
self-referential <link rel=canonical>; add WebApplication JSON-LD to the home page.
- Pin jinja2.
* SEO: server-rendered per-city climate page (/climate/{slug})
The keystone crawlable page: for a city it snaps to the grid cell, loads the
archive (fetching once if missing, self-healing), and renders as real HTML — a
'how today compares' block (grade + percentile per metric from grade_day, tinted
by tier), a monthly normals table (climatology at each month's 15th, shown in °F
and °C), all-time records (new grading.all_time_records helper), a breadcrumb,
Dataset+Place+BreadcrumbList JSON-LD, self-referential canonical, and links into
the interactive tool + month/records pages. Content-page CSS added to style.css
(renamed the table class to avoid colliding with the app's .normals flex row).
* SEO: month (/climate/{slug}/{month}) and records (/climate/{slug}/records) pages
Month pages render the exact-month long-tail ('average weather in {city} in
{month}') with that month's average high/low, typical p10-p90 range, month-specific
records, and prev/next month links. Records pages show all-time record highs/lows
per metric with dates (grading.all_time_records). Shared _resolve_city helper; the
literal /records route is registered before the {month} param and month names are
validated (unknown month -> 404).
* SEO: climate hub, weather glossary, and about/methodology pages
- /climate: crawlable directory of all ~500 cities grouped by country — the
internal-link graph that lets search engines discover every city page.
- /glossary + /glossary/{term}: plain-language definitions (climate normal,
percentile, temperature anomaly, feels-like, heat index, wind chill, humidity,
reanalysis) with DefinedTerm JSON-LD and cross-links into the tool.
- /about: methodology page (ERA5 data source, 45-year baseline, +/-7-day window,
percentile grading) for E-E-A-T. All linked from the shared footer.
* SEO: archive warmer, content-page tests, and deploy docs
- warm_cities.py: paced, idempotent offline warmer that pre-fetches each city
cell's archive so /climate pages serve from cache and a crawl can't burst the
archive quota (pages self-heal if hit before warming).
- tests/test_content.py: city-set slug uniqueness/lookup, robots.txt, sitemap
enumerating city/month/records URLs, and that a rendered city page carries the
stats + canonical + Dataset JSON-LD in the HTML; plus month/records/hub/glossary/
about routing and 404s.
- DEPLOY.md: document the content pages, the warm step, and submitting the sitemap.
2026-07-15 23:53:11 +00:00
|
|
|
args = sys.argv[1:]
|
|
|
|
|
lim = int(args[args.index("--limit") + 1]) if "--limit" in args else None
|
|
|
|
|
pc = float(args[args.index("--pace") + 1]) if "--pace" in args else 2.0
|
|
|
|
|
main(limit=lim, pace=pc)
|