Fix BreadcrumbList JSON-LD: omit unlinked intermediate crumbs (#164)

Google Search Console flags city pages with 'Missing field item (in
itemListElement)': the country crumb has no page of its own, so its
ListItem was emitted without the required item URL. Google only allows
the final ListItem to omit item, so drop unlinked intermediate crumbs
from the structured data (the visible breadcrumb is unchanged) and
renumber positions. Shared helper now builds the BreadcrumbList for
both the city and records pages, with a regression test parsing the
emitted JSON-LD.

Claude-Session: https://claude.ai/code/session_01KdTZCjpLeD26ZbXe6ApjpR
This commit is contained in:
Emi Griffith 2026-07-17 06:04:51 -07:00 committed by GitHub
parent ab6303510a
commit ad68caa754
2 changed files with 34 additions and 10 deletions

View file

@ -147,6 +147,19 @@ def origin(request: Request) -> str:
return f"{proto}://{host}"
def _breadcrumb_jsonld(o: str, breadcrumb: list[tuple[str, str | None]]) -> dict:
"""BreadcrumbList structured data. Google requires ``item`` on every
ListItem except the last, so unlinked intermediate crumbs (e.g. the
country, which has no page of its own) are omitted here even though the
visible breadcrumb shows them."""
crumbs = [c for c in breadcrumb[:-1] if c[1]] + breadcrumb[-1:]
return {"@type": "BreadcrumbList",
"itemListElement": [
{"@type": "ListItem", "position": i + 1, "name": nm,
**({"item": f"{o}{href}"} if href else {})}
for i, (nm, href) in enumerate(crumbs)]}
def _respond_html(request: Request, template: str, **ctx) -> Response:
o = origin(request)
html = _env.get_template(template).render(base=BASE, origin=o, base_url=f"{o}{BASE}", **ctx)
@ -392,11 +405,7 @@ def _city_context(request, city, cell, history) -> dict:
"latitude": city["lat"], "longitude": city["lon"]}},
"creator": {"@type": "Organization", "name": "Thermograph"},
"isBasedOn": "https://open-meteo.com/ (ERA5 reanalysis)"},
{"@type": "BreadcrumbList",
"itemListElement": [
{"@type": "ListItem", "position": i + 1, "name": nm,
**({"item": f"{o}{href}"} if href else {})}
for i, (nm, href) in enumerate(breadcrumb)]},
_breadcrumb_jsonld(o, breadcrumb),
],
}
return {
@ -573,11 +582,7 @@ def _records_context(request, city, history) -> dict:
"latitude": city["lat"], "longitude": city["lon"]}},
"creator": {"@type": "Organization", "name": "Thermograph"},
"isBasedOn": "https://open-meteo.com/ (ERA5 reanalysis)"},
{"@type": "BreadcrumbList",
"itemListElement": [
{"@type": "ListItem", "position": i + 1, "name": nm,
**({"item": f"{o}{href}"} if href else {})}
for i, (nm, href) in enumerate(breadcrumb)]},
_breadcrumb_jsonld(o, breadcrumb),
],
}
return {

View file

@ -56,6 +56,25 @@ def test_city_page_renders_stats_in_html(client):
assert "°F" in b
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"
def test_city_404(client):
assert client.get(f"{B}/climate/nope-not-a-city").status_code == 404