From ad68caa754effe783727c336acb600185610301a Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Fri, 17 Jul 2026 06:04:51 -0700 Subject: [PATCH] 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 --- content.py | 25 +++++++++++++++---------- tests/test_content.py | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/content.py b/content.py index a555c82..4bb1c04 100644 --- a/content.py +++ b/content.py @@ -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 { diff --git a/tests/test_content.py b/tests/test_content.py index 83b1579..070e2be 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -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'', 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