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:
parent
ab6303510a
commit
ad68caa754
2 changed files with 34 additions and 10 deletions
25
content.py
25
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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue