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
|
|
|
|
"""Tests for the crawlable SEO content pages: the city set, robots/sitemap, and
|
|
|
|
|
|
that the server-rendered pages contain the stats in the HTML (with the weather
|
|
|
|
|
|
layer faked, like test_api.py)."""
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
|
|
|
|
|
|
import app as appmod
|
|
|
|
|
|
import cities
|
|
|
|
|
|
import climate
|
|
|
|
|
|
|
|
|
|
|
|
# BASE defaults to /thermograph in tests (THERMOGRAPH_BASE unset).
|
|
|
|
|
|
B = "/thermograph"
|
|
|
|
|
|
SLUG = "london-england-gb" # present in cities.json
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
def client(monkeypatch, history, recent):
|
|
|
|
|
|
monkeypatch.setattr(climate, "load_cached_history", lambda cell: history.clone())
|
|
|
|
|
|
monkeypatch.setattr(climate, "get_recent_forecast", lambda cell: recent.clone())
|
|
|
|
|
|
monkeypatch.setattr(climate, "get_history", lambda cell: (history.clone(), {"cached": True}))
|
|
|
|
|
|
return TestClient(appmod.app)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_city_set_slugs_unique_and_lookup():
|
|
|
|
|
|
slugs = cities.all_slugs()
|
|
|
|
|
|
assert len(slugs) == len(set(slugs)) >= 100
|
|
|
|
|
|
assert cities.get(SLUG) is not None
|
|
|
|
|
|
assert cities.get("does-not-exist") is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_robots_txt(client):
|
|
|
|
|
|
r = client.get(f"{B}/robots.txt")
|
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
|
assert "Sitemap:" in r.text and "/sitemap.xml" in r.text
|
|
|
|
|
|
assert "Disallow: /thermograph/api/" in r.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sitemap_lists_city_urls(client):
|
|
|
|
|
|
r = client.get(f"{B}/sitemap.xml")
|
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
|
assert "<urlset" in r.text
|
|
|
|
|
|
assert f"/climate/{SLUG}</loc>" in r.text
|
|
|
|
|
|
assert f"/climate/{SLUG}/july</loc>" in r.text
|
|
|
|
|
|
assert f"/climate/{SLUG}/records</loc>" in r.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_city_page_renders_stats_in_html(client):
|
|
|
|
|
|
r = client.get(f"{B}/climate/{SLUG}")
|
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
|
b = r.text
|
|
|
|
|
|
assert "London" in b and "climate" in b.lower()
|
|
|
|
|
|
assert 'rel="canonical"' in b and f"/climate/{SLUG}" in b
|
|
|
|
|
|
assert '"@type":"Dataset"' in b
|
|
|
|
|
|
assert "average temperatures by month" in b.lower()
|
|
|
|
|
|
# a real number rendered (°F appears in the normals/today text)
|
|
|
|
|
|
assert "°F" in b
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-17 13:04:51 +00:00
|
|
|
|
def _jsonld_breadcrumbs(html: str) -> list[dict]:
|
|
|
|
|
|
import json, re
|
|
|
|
|
|
m = re.search(r'<script type="application/ld\+json">(.*?)</script>', html, re.S)
|
|
|
|
|
|
assert m, "no JSON-LD block on page"
|
|
|
|
|
|
graph = json.loads(m.group(1))["@graph"]
|
|
|
|
|
|
return [n for n in graph if n["@type"] == "BreadcrumbList"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("path", [f"/climate/{SLUG}", f"/climate/{SLUG}/records"])
|
|
|
|
|
|
def test_breadcrumb_jsonld_items_valid_for_google(client, path):
|
|
|
|
|
|
# Google: every ListItem except the last must carry an ``item`` URL, so
|
|
|
|
|
|
# unlinked intermediate crumbs (the country) must not appear in JSON-LD.
|
|
|
|
|
|
(bc,) = _jsonld_breadcrumbs(client.get(B + path).text)
|
|
|
|
|
|
els = bc["itemListElement"]
|
|
|
|
|
|
assert [e["position"] for e in els] == list(range(1, len(els) + 1))
|
|
|
|
|
|
for e in els[:-1]:
|
|
|
|
|
|
assert e["item"].startswith("http"), f"crumb {e['name']!r} missing item"
|
|
|
|
|
|
|
|
|
|
|
|
|
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 test_city_404(client):
|
|
|
|
|
|
assert client.get(f"{B}/climate/nope-not-a-city").status_code == 404
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 01:23:45 +00:00
|
|
|
|
def test_curated_events_have_valid_slugs():
|
|
|
|
|
|
import city_events
|
|
|
|
|
|
slugs = set(cities.all_slugs())
|
|
|
|
|
|
assert len(city_events.EVENTS) >= 20
|
|
|
|
|
|
assert all(s in slugs for s in city_events.EVENTS), \
|
|
|
|
|
|
[s for s in city_events.EVENTS if s not in slugs]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_curated_event_renders(client):
|
|
|
|
|
|
b = client.get(f"{B}/climate/new-orleans-louisiana-us").text
|
|
|
|
|
|
assert "Notable weather in New Orleans" in b
|
|
|
|
|
|
assert "Hurricane Katrina" in b
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_uncurated_city_has_no_event_section(client):
|
|
|
|
|
|
b = client.get(f"{B}/climate/shanghai-cn").text
|
|
|
|
|
|
assert "city-event" not in b # falls back to the flavor blurb
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 00:39:47 +00:00
|
|
|
|
def test_city_travel_cta_prefills_compare(client):
|
|
|
|
|
|
b = client.get(f"{B}/climate/{SLUG}").text
|
|
|
|
|
|
assert "Thinking of visiting" in b
|
|
|
|
|
|
assert "/compare#loc=" in b # the city is pre-loaded on the compare page
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_city_blurb_renders_with_attribution(client, monkeypatch):
|
|
|
|
|
|
monkeypatch.setattr(cities, "flavor",
|
|
|
|
|
|
lambda slug: {"extract": "Testville is a lovely place to test.",
|
|
|
|
|
|
"url": "https://en.wikipedia.org/wiki/Testville", "title": "Testville"})
|
|
|
|
|
|
b = client.get(f"{B}/climate/{SLUG}").text
|
|
|
|
|
|
assert "Testville is a lovely place to test." in b
|
|
|
|
|
|
assert "via Wikipedia" in b # CC BY-SA attribution link
|
|
|
|
|
|
|
|
|
|
|
|
|
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 test_month_and_records(client):
|
|
|
|
|
|
assert client.get(f"{B}/climate/{SLUG}/july").status_code == 200
|
|
|
|
|
|
assert client.get(f"{B}/climate/{SLUG}/records").status_code == 200
|
|
|
|
|
|
assert client.get(f"{B}/climate/{SLUG}/notamonth").status_code == 404
|
|
|
|
|
|
|
|
|
|
|
|
|
Month pages: show record high and low for every metric (#113)
The per-month records section listed only the warmest and coldest temperature.
Extend it to a card per metric (High, Low, Feels-like, Humidity, Wind, Gust,
Precip), each showing that calendar month's record high and low with the date.
Precip is special-cased: a per-day record low is just zero, and the dry-streak
helper would bridge year boundaries on month-filtered rows, so the wettest and
driest sides use the month's largest and smallest total accumulation, dated to
the year. Partial current months (< 26 days) are excluded so an unfinished month
can't win "driest" on a fraction of its rainfall.
Reuses the records page's card grid, so it's already vertical on mobile.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-16 05:13:51 +00:00
|
|
|
|
def test_month_records_cover_every_metric_high_and_low(client):
|
|
|
|
|
|
# The month page's records show a record high AND low for each metric present, not
|
|
|
|
|
|
# just warmest/coldest temperature — one card per metric with both extremes. (The
|
|
|
|
|
|
# test fixture carries tmax/tmin/precip; real cities add feels/humidity/wind/gust.)
|
|
|
|
|
|
b = client.get(f"{B}/climate/{SLUG}/july").text
|
|
|
|
|
|
assert "July records" in b
|
|
|
|
|
|
for label in ("High", "Low", "Precip"):
|
|
|
|
|
|
assert f'class="rc-metric">{label}<' in b
|
|
|
|
|
|
# Every card carries a labelled high and low row (temp metrics use Highest/Lowest).
|
|
|
|
|
|
n = b.count('rc-row rc-high')
|
|
|
|
|
|
assert n >= 3 and b.count('rc-row rc-low') == n
|
|
|
|
|
|
assert "Highest" in b and "Lowest" in b
|
|
|
|
|
|
# Precip's extremes are the wettest/driest whole month by total accumulation.
|
|
|
|
|
|
assert "Wettest" in b and "Driest" in b
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 03:50:59 +00:00
|
|
|
|
def test_records_page_has_monthly_and_seasonal(client):
|
|
|
|
|
|
b = client.get(f"{B}/climate/{SLUG}/records").text
|
|
|
|
|
|
# Monthly records: all 12 months, each linking to its month page.
|
|
|
|
|
|
assert "Records by month" in b
|
|
|
|
|
|
for month in ("January", "July", "December"):
|
|
|
|
|
|
assert month in b
|
|
|
|
|
|
assert f"/climate/{SLUG}/january" in b
|
|
|
|
|
|
# Seasonal records: labelled for London's Northern Hemisphere, with month spans.
|
|
|
|
|
|
assert "Records by season" in b
|
|
|
|
|
|
assert "Northern Hemisphere" in b
|
|
|
|
|
|
assert 'Winter <span class="season-span">(Dec–Feb)</span>' in b
|
|
|
|
|
|
assert 'Summer <span class="season-span">(Jun–Aug)</span>' in b
|
|
|
|
|
|
# All-time table still present, plus structured data and real °F values.
|
|
|
|
|
|
assert "All-time records" in b
|
|
|
|
|
|
assert '"@type":"Dataset"' in b and "°F" in b
|
|
|
|
|
|
# Title/description advertise the new coverage.
|
|
|
|
|
|
assert "monthly" in b.lower() and "seasonal" in b.lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_records_seasons_flip_in_southern_hemisphere(client):
|
|
|
|
|
|
# São Paulo is south of the equator, so Dec–Feb is summer, not winter.
|
|
|
|
|
|
b = client.get(f"{B}/climate/sao-paulo-br/records").text
|
|
|
|
|
|
assert "Southern Hemisphere" in b
|
|
|
|
|
|
assert 'Summer <span class="season-span">(Dec–Feb)</span>' in b
|
|
|
|
|
|
assert 'Winter <span class="season-span">(Jun–Aug)</span>' in b
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_climate_pages_are_colour_coded(client):
|
|
|
|
|
|
# Temperatures wear the diverging cold→hot palette (a heat map, not a plain grid),
|
|
|
|
|
|
# and the city page carries the monthly temperature-range strip.
|
|
|
|
|
|
city = client.get(f"{B}/climate/{SLUG}").text
|
|
|
|
|
|
assert "t-cell t-" in city # tinted temperature cells in the normals table
|
|
|
|
|
|
assert "range-fill" in city and "--c1:var(--" in city # the monthly range strip's gradient bars
|
|
|
|
|
|
recs = client.get(f"{B}/climate/{SLUG}/records").text
|
2026-07-16 04:57:13 +00:00
|
|
|
|
assert "t-inline t-" in recs # tinted record chips
|
2026-07-16 03:50:59 +00:00
|
|
|
|
month = client.get(f"{B}/climate/{SLUG}/july").text
|
|
|
|
|
|
assert "t-inline t-" in month # tinted hero numbers + records
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 04:57:13 +00:00
|
|
|
|
def test_records_show_both_extremes_per_metric(client):
|
|
|
|
|
|
# Each metric column shows BOTH its record warmest (▲) and coldest (▼) — so the
|
|
|
|
|
|
# daytime high carries its record-low high, and the overnight low its record-high low.
|
|
|
|
|
|
b = client.get(f"{B}/climate/{SLUG}/records").text
|
|
|
|
|
|
assert "Daytime high" in b and "Overnight low" in b
|
|
|
|
|
|
assert "▲" in b and "▼" in b
|
|
|
|
|
|
# 12 months × 2 metric columns × 2 extremes = 48 chips just in the monthly table.
|
|
|
|
|
|
assert b.count('class="rec-ext"') >= 48
|
|
|
|
|
|
|
|
|
|
|
|
|
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 test_hub_glossary_about(client):
|
|
|
|
|
|
assert client.get(f"{B}/climate").status_code == 200
|
|
|
|
|
|
assert client.get(f"{B}/glossary").status_code == 200
|
|
|
|
|
|
assert client.get(f"{B}/glossary/percentile").status_code == 200
|
|
|
|
|
|
assert client.get(f"{B}/glossary/not-a-term").status_code == 404
|
|
|
|
|
|
assert client.get(f"{B}/about").status_code == 200
|
2026-07-16 17:48:30 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_climate_pages_carry_convertible_temps(client):
|
|
|
|
|
|
# Every temperature is a client-convertible span (default °F text so crawlers
|
|
|
|
|
|
# and no-JS visitors still see a real value); the old inline "(NN°C)" dual form
|
|
|
|
|
|
# is gone (the toggle now supplies Celsius).
|
|
|
|
|
|
import re
|
|
|
|
|
|
for path in (f"{B}/climate/{SLUG}", f"{B}/climate/{SLUG}/july", f"{B}/climate/{SLUG}/records"):
|
|
|
|
|
|
b = client.get(path).text
|
|
|
|
|
|
assert 'class="temp" data-temp-f=' in b
|
|
|
|
|
|
assert "°F" in b
|
|
|
|
|
|
assert re.search(r"\d+°F \(-?\d+°C\)", b) is None # no leftover dual-unit strings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_seo_pages_load_unit_and_account_scripts(client):
|
|
|
|
|
|
# Header parity with the interactive pages: the °F/°C toggle, account/bell, and
|
|
|
|
|
|
# the temperature converter are all loaded.
|
|
|
|
|
|
b = client.get(f"{B}/climate/{SLUG}").text
|
|
|
|
|
|
for src in (f"{B}/units.js", f"{B}/account.js", f"{B}/climate.js"):
|
|
|
|
|
|
assert f'src="{src}"' in b
|
2026-07-16 18:08:09 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_indexnow_key_file_served(client):
|
|
|
|
|
|
import indexnow
|
|
|
|
|
|
k = indexnow.key()
|
|
|
|
|
|
r = client.get(f"{B}/{k}.txt")
|
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
|
assert r.text.strip() == k
|
|
|
|
|
|
assert r.headers["content-type"].startswith("text/plain")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sitemap_lastmod_is_stable(client):
|
|
|
|
|
|
import datetime
|
|
|
|
|
|
import re
|
|
|
|
|
|
body = client.get(f"{B}/sitemap.xml").text
|
|
|
|
|
|
mods = set(re.findall(r"<lastmod>([^<]+)</lastmod>", body))
|
|
|
|
|
|
assert len(mods) == 1 # one build date, not a per-request churn
|
|
|
|
|
|
datetime.date.fromisoformat(next(iter(mods))) # a valid ISO date
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_indexnow_submit_all_builds_payload(monkeypatch):
|
|
|
|
|
|
import indexnow
|
|
|
|
|
|
|
|
|
|
|
|
calls = []
|
|
|
|
|
|
|
|
|
|
|
|
class _Resp:
|
|
|
|
|
|
status_code = 200
|
|
|
|
|
|
text = "ok"
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(indexnow.httpx, "post",
|
|
|
|
|
|
lambda url, json=None, **kw: (calls.append((url, json)), _Resp())[1])
|
|
|
|
|
|
res = indexnow.submit_all("https://thermograph.org")
|
|
|
|
|
|
assert res["submitted"] == res["total"] > 100
|
|
|
|
|
|
first = calls[0][1]
|
|
|
|
|
|
assert first["host"] == "thermograph.org"
|
|
|
|
|
|
assert first["keyLocation"] == f"https://thermograph.org/{indexnow.key()}.txt"
|
|
|
|
|
|
assert first["urlList"][0] == "https://thermograph.org/"
|
|
|
|
|
|
assert all(len(c[1]["urlList"]) <= 10000 for c in calls) # batched under the IndexNow cap
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 20:38:59 +00:00
|
|
|
|
def test_indexnow_if_changed_state(monkeypatch, tmp_path):
|
|
|
|
|
|
import indexnow
|
|
|
|
|
|
monkeypatch.setattr(indexnow, "_STATE_PATH", str(tmp_path / "state.txt"))
|
|
|
|
|
|
sig = indexnow.url_signature()
|
|
|
|
|
|
assert len(sig) == 40 and sig == indexnow.url_signature() # stable sha1 of the URL set
|
|
|
|
|
|
assert indexnow._read_state() == "" # first deploy → would submit
|
|
|
|
|
|
indexnow._write_state(sig)
|
|
|
|
|
|
assert indexnow._read_state() == sig # unchanged → deploy would skip
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 18:08:09 +00:00
|
|
|
|
def test_search_verification_meta(client, monkeypatch):
|
|
|
|
|
|
monkeypatch.setenv("THERMOGRAPH_GOOGLE_VERIFY", "gtok123")
|
|
|
|
|
|
monkeypatch.setenv("THERMOGRAPH_BING_VERIFY", "btok456")
|
|
|
|
|
|
seo = client.get(f"{B}/climate/{SLUG}").text # Jinja SEO page
|
|
|
|
|
|
home = client.get(f"{B}/").text # static homepage via _page()
|
|
|
|
|
|
for b in (seo, home):
|
|
|
|
|
|
assert '<meta name="google-site-verification" content="gtok123">' in b
|
|
|
|
|
|
assert '<meta name="msvalidate.01" content="btok456">' in b
|