fix(seo): make lastmod describe the page, not the row behind it - #10483
Conversation
Measured against Search Console: Google last fetched /heatmap-annotated/python/altair on 2026-07-29 and /bar-error/python/matplotlib on 2026-08-07, and last read the sitemap on 2026-08-14 — all of it before today's work. Everything shipped since is invisible to anything grounding on Google's index, which is exactly the symptom the owner reported: Mistral, which fetches live, works; Gemini, which does not, still describes the old page. The sitemap was actively working against that. lastmod came from an implementation's `updated` column, and today changed the rendering rather than the data — new render markup, both themes, the interactive version, a rewritten meta description — so every date stayed put and the file told Google nothing had changed. It is the one signal that asks a crawler to come back, and it was saying "don't bother". lastmod is now the later of the record's own date and TEMPLATE_LAST_CHANGED. That constant is to be bumped only when the rendered page genuinely changes for every URL: it asserts that ~3,900 pages changed at once, and making that claim casually is how a site teaches Google to stop trusting its lastmod. A missing `updated` no longer yields a missing lastmod either. The page was last modified at least when its template was, and saying nothing was the same lost signal in a smaller form. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Updates sitemap <lastmod> generation so it reflects when the rendered page template changed (not only when DB rows changed), improving recrawl signaling for search engines and bots.
Changes:
- Introduces
TEMPLATE_LAST_CHANGEDand usesmax(record.updated, TEMPLATE_LAST_CHANGED)to compute sitemap<lastmod>. - Updates unit tests to pin the new lastmod contract (newer record wins; older/None record yields template date).
- Documents the new lastmod semantics and adds a changelog entry.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| api/routers/seo.py | Adds TEMPLATE_LAST_CHANGED and updates _lastmod() to prefer the later of record date vs template date. |
| tests/unit/api/test_seo_helpers.py | Updates/extends tests to assert the new <lastmod> behavior. |
| docs/reference/seo.md | Documents the “page vs row” lastmod rule and when to bump the template constant. |
| CHANGELOG.md | Adds an Unreleased “Fixed” entry describing the sitemap recrawl signal correction. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Both from Copilot, both cases of prose outrunning the code. _lastmod's docstring still said it could return empty. It cannot: the template date is always defined, so every URL now gets an element — which is the point of the change, and the tests assert it. The sitemap section described lastmod as coming from an implementation's updated date, while hub URLs use spec.updated. Now says 'the record's', naming both tiers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Both applied — prose outrunning the code in each case. The And the sitemap section did describe only the implementation tier while hub URLs use |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (1)
api/routers/seo.py:45
_lastmodultimately emits a YYYY-MM-DD string, but it currently compares fulldatetimeobjects viamax(dt, TEMPLATE_LAST_CHANGED). Comparing datetimes is unnecessary here and can fail ifdtis ever timezone-aware (offset-aware vs naive comparisons raiseTypeError). Consider storingTEMPLATE_LAST_CHANGEDas adateand comparingdates only, then emit viaisoformat().
TEMPLATE_LAST_CHANGED = datetime(2026, 8, 18)
def _lastmod(dt: datetime | None) -> str:
"""Format the later of the record's own date and the template's, as <lastmod>.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Measured, not guessed
Search Console, queried just now:
Everything shipped today is invisible to anything grounding on Google's index. That is precisely the symptom reported from live testing: Mistral works, Gemini does not — Mistral fetches the page, Gemini reads Google's copy, and Google's copy is weeks old.
The sitemap was arguing against a recrawl
lastmodcame from an implementation'supdatedcolumn. Today changed the rendering, not the data — real render markup, both themes, the interactive version, a rewritten meta description — so every date stayed exactly where it was, and the sitemap reported that nothing had changed.lastmodis the one signal that asks a crawler to come back, and it was saying don't bother.Fix
lastmodis now the later of the record's own date andTEMPLATE_LAST_CHANGED.Bump it 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
lastmodaltogether. The constant carries that warning in a comment, and the docs repeat it.A missing
updatedno longer yields a missinglastmodeither — the page was last modified at least when its template was, and saying nothing was the same lost signal in a smaller form.Verification
pytest tests/unit— 1658 passed. ThreeTestLastmodcases now pin the contract explicitly: a newer record wins, an older one is lifted, and no record still yields the template dateruff check+ruff format --check— clean2026-08-18Needs a manual step to take effect
The sitemap is only re-read every few days on its own. Resubmitting it in Search Console (Sitemaps → the existing entry → resubmit) pairs with this bump and is the fastest way to get the recrawl started. Same page also offers URL Inspection → Request indexing for a handful of priority URLs, capped around ten a day.
🤖 Generated with Claude Code