Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,15 @@ aggregate instead: an italic *Catalog* line at the end of the version section an

### Fixed

- **The sitemap was telling Google nothing had changed** — `lastmod` came from an implementation's
`updated` column, which does not move when the page's *rendering* changes. So after a day of work
that gave every implementation page the real render, both themes, the interactive version and a
rewritten description, the sitemap still reported the old dates, and Google — last seen fetching
some of those pages three weeks earlier — had no reason to return. `lastmod` is now the later of
the record's date and `TEMPLATE_LAST_CHANGED`, a constant to bump only when the rendered page
changes for every URL; claiming 3,900 changes casually is how a site teaches Google to ignore its
`lastmod` (#10483).

- **CodeQL alert #103** — an ECharts tooltip called `.replace("\n", " ")` with a string argument,
which replaces only the first occurrence
(`plots/bar-heart-rate-zones/implementations/javascript/echarts.js`). Nothing renders differently
Expand Down
24 changes: 22 additions & 2 deletions api/routers/seo.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,29 @@
_SPEC_ID_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")


# The date the bot-facing page template last changed materially. `lastmod`
# describes the PAGE, not the row behind it, and those drift apart: on
# 2026-08-18 every implementation page gained the real render, both themes, the
# interactive version and a rewritten meta description, while no `updated`
# column moved. The sitemap consequently told Google nothing had changed, and
# Google — which had last fetched some of these pages three weeks earlier — had
# no reason to come back and see any of it.
#
# Bump this ONLY when the rendered page genuinely changes for every URL. It is
# a claim to search engines that ~3,900 pages changed at once; making it
# casually is how a site teaches Google to stop trusting its lastmod.
TEMPLATE_LAST_CHANGED = datetime(2026, 8, 18)


def _lastmod(dt: datetime | None) -> str:
"""Format datetime as <lastmod> XML element, or empty string if None."""
return f"<lastmod>{dt.strftime('%Y-%m-%d')}</lastmod>" if dt else ""
"""Format the later of the record's own date and the template's, as <lastmod>.

Always returns an element. A record without its own date still has a page,
and that page was last modified at least when its template was — emitting
nothing there was the same lost recrawl signal in a smaller form.
"""
latest = max(dt, TEMPLATE_LAST_CHANGED) if dt else TEMPLATE_LAST_CHANGED
return f"<lastmod>{latest.strftime('%Y-%m-%d')}</lastmod>"


def _build_sitemap_xml(specs: list) -> str:
Expand Down
19 changes: 19 additions & 0 deletions docs/reference/seo.md
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,25 @@ filtering is served as `/{spec_id}?language={language}` (the hub with a filter
query param, same canonical as the unfiltered hub), so listing it would create
duplicate-content entries for Google.

### `lastmod` describes the page, not the row

`lastmod` is the later of the record's own `updated` date — `spec.updated` for a
hub URL, `impl.updated` for an implementation URL — and `TEMPLATE_LAST_CHANGED`
in `api/routers/seo.py`. The two drift apart, and the
drift matters: on 2026-08-18 every implementation page gained the real render,
both themes, the interactive version and a rewritten meta description, while no
`updated` column moved. The sitemap accordingly told Google nothing had changed
— and Google, which had last fetched some of those pages three weeks earlier,
had no reason to come back and see any of it.

Bump `TEMPLATE_LAST_CHANGED` **only** when the rendered page genuinely changes
for every URL. It asserts to search engines that ~3,900 pages changed at once;
making that claim casually is how a site teaches Google to ignore its `lastmod`
altogether.

A sitemap resubmission in Search Console pairs with the bump — the file is only
re-read every few days on its own.

### Included URLs

1. Home page (`/`)
Expand Down
39 changes: 24 additions & 15 deletions tests/unit/api/test_seo_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from api.routers.seo import (
_HOME_JSONLD,
_META_DESCRIPTION_LIMIT,
TEMPLATE_LAST_CHANGED,
_build_home_body,
_build_impl_html,
_build_sitemap_xml,
Expand Down Expand Up @@ -55,20 +56,21 @@ def _mock_spec(impls: list) -> MagicMock:


class TestLastmod:
"""Tests for _lastmod helper."""
"""lastmod describes the page, which is not the same as the row behind it."""

def test_with_datetime(self) -> None:
dt = datetime(2025, 3, 15)
result = _lastmod(dt)
assert result == "<lastmod>2025-03-15</lastmod>"
def test_a_record_newer_than_the_template_wins(self) -> None:
dt = datetime(2099, 3, 15)
assert _lastmod(dt) == "<lastmod>2099-03-15</lastmod>"

def test_with_none(self) -> None:
assert _lastmod(None) == ""
def test_an_older_record_is_lifted_to_the_template_date(self) -> None:
"""The row has not moved, but the page it renders into has."""
stamp = TEMPLATE_LAST_CHANGED.strftime("%Y-%m-%d")
assert _lastmod(datetime(2024, 12, 1, 10, 30, 0)) == f"<lastmod>{stamp}</lastmod>"

def test_with_different_date(self) -> None:
dt = datetime(2024, 12, 1, 10, 30, 0)
result = _lastmod(dt)
assert result == "<lastmod>2024-12-01</lastmod>"
def test_without_a_record_date_the_template_date_still_applies(self) -> None:
"""The page was last modified at least when its template was."""
stamp = TEMPLATE_LAST_CHANGED.strftime("%Y-%m-%d")
assert _lastmod(None) == f"<lastmod>{stamp}</lastmod>"


class TestBuildSitemapXml:
Expand Down Expand Up @@ -115,8 +117,12 @@ def test_spec_with_impls(self) -> None:
assert "<loc>https://anyplot.ai/scatter-basic/python</loc>" not in result
# Legacy /python/{spec} path must NOT appear
assert "https://anyplot.ai/python/scatter-basic" not in result
assert "<lastmod>2025-03-14</lastmod>" in result
assert "<lastmod>2025-03-15</lastmod>" in result
# The record's own date is older than the template's, so the page's
# lastmod is the template's — the page changed even though the row did not.
stamp = TEMPLATE_LAST_CHANGED.strftime("%Y-%m-%d")
assert f"<lastmod>{stamp}</lastmod>" in result
assert "<lastmod>2025-03-14</lastmod>" not in result
assert "<lastmod>2025-03-15</lastmod>" not in result

def test_spec_without_impls_excluded(self) -> None:
spec = MagicMock()
Expand Down Expand Up @@ -223,8 +229,11 @@ def test_spec_with_none_updated(self) -> None:
spec.updated = None

result = _build_sitemap_xml([spec])
# Should not have lastmod when updated is None
assert "<loc>https://anyplot.ai/scatter-basic</loc></url>" in result
# A missing `updated` no longer means a missing lastmod: the page was
# last modified at least when its template was, and saying nothing left
# Google with no reason to recrawl pages whose rendering had changed.
stamp = TEMPLATE_LAST_CHANGED.strftime("%Y-%m-%d")
assert f"<loc>https://anyplot.ai/scatter-basic</loc><lastmod>{stamp}</lastmod></url>" in result


class TestRenderBotHtml:
Expand Down
Loading