thermograph/templates/city.html.j2
Emi Griffith 58ac3120b2 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

75 lines
3.7 KiB
Django/Jinja
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html.j2" %}
{% block title %}{{ page_title }}{% endblock %}
{% block description %}{{ page_description }}{% endblock %}
{% block canonical_path %}{{ canonical_path }}{% endblock %}
{% block jsonld %}<script type="application/ld+json">{{ jsonld_str | safe }}</script>{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="Breadcrumb">
{% for label, href in breadcrumb %}{% if href %}<a href="{{ href }}">{{ label }}</a>{% else %}<span>{{ label }}</span>{% endif %}{% if not loop.last %} <span class="sep"></span> {% endif %}{% endfor %}
</nav>
<article class="climate-page">
<h1>{{ display }} climate</h1>
<p class="lede">Average temperatures, precipitation and all-time records for <b>{{ display }}</b>,
with every day graded against ~{{ n_years }} years of local climate history
({{ year_range[0] }}{{ year_range[1] }}). See where {% if warmest %}the warmest month is
<b>{{ warmest.name }}</b> ({{ warmest.high }} average high){% endif %}{% if coldest %} and the coldest
is <b>{{ coldest.name }}</b> ({{ coldest.low }} average low){% endif %}.</p>
{% if today %}
<section class="climate-today">
<h2>How today compares</h2>
<p class="muted">Latest recorded day: {{ today.date }} — each reading placed on {{ display }}'s
own ±7-day seasonal distribution.</p>
<div class="today-cards">
{% for c in today.cards %}
<div class="today-card" style="border-left-color: var(--{{ c.cls }})">
<span class="tc-label">{{ c.label }}</span>
<span class="tc-value">{{ c.value }}</span>
{% if c.percentile is not none %}<span class="tc-grade">{{ c.grade }} · {{ c.percentile }}th pct</span>
{% else %}<span class="tc-grade">{{ c.grade }}</span>{% endif %}
</div>
{% endfor %}
</div>
<p><a class="cta" href="{{ base }}/#{{ tool_hash }}">Open {{ name }} in the live weather grader →</a></p>
</section>
{% endif %}
<section class="climate-normals">
<h2>{{ name }} average temperatures by month</h2>
<p>Typical daily high and low, and average precipitation, for each month in {{ display }},
based on {{ year_range[0] }}{{ year_range[1] }} records.
{% if wettest %}The wettest month is <b>{{ wettest.name }}</b> ({{ wettest.precip }} on an average day).{% endif %}</p>
<div class="table-wrap">
<table class="normals-table">
<thead><tr><th>Month</th><th>Avg high</th><th>Avg low</th><th>Avg precip</th></tr></thead>
<tbody>
{% for m in months %}
<tr>
<td><a href="{{ base }}/climate/{{ city.slug }}/{{ m.slug }}">{{ m.name }}</a></td>
<td>{{ m.high }}</td><td>{{ m.low }}</td><td>{{ m.precip }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% if records.tmax or records.tmin %}
<section class="climate-records">
<h2>Record extremes</h2>
<ul>
{% if records.tmax %}<li><b>Hottest day on record:</b> {{ records.tmax.max | round | int }}°F
({{ ((records.tmax.max - 32) * 5 / 9) | round | int }}°C) on {{ records.tmax.max_date }}</li>{% endif %}
{% if records.tmin %}<li><b>Coldest day on record:</b> {{ records.tmin.min | round | int }}°F
({{ ((records.tmin.min - 32) * 5 / 9) | round | int }}°C) on {{ records.tmin.min_date }}</li>{% endif %}
</ul>
<p><a href="{{ base }}/climate/{{ city.slug }}/records">All {{ name }} weather records →</a></p>
</section>
{% endif %}
<p class="climate-foot muted">Percentiles compare each day to {{ display }}'s own history, so grades are
relative to this location — a “Near Record” day here isn't the same temperature as elsewhere.
<a href="{{ base }}/legend">How the grades work →</a></p>
</article>
{% endblock %}