diff --git a/CHANGELOG.md b/CHANGELOG.md index a24df73e936..726db080551 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,15 @@ aggregate instead: an italic *Catalog* line at the end of the version section an ### Fixed +- **Implementation pages no longer share one meta description per spec** — the SEO + proxy reused the spec description verbatim as the `` and OG + description of every implementation page, so up to 15 library pages and their hub + carried an identical snippet; 4,254 plot pages had about 334 distinct descriptions, + which Bing Webmaster Tools flagged as "too many pages with identical meta descriptions". + The snippet now opens with the page's identity, `{title} in {library} ({language}): + {description}`, trimmed to 155 characters as before, so a searcher who typed the + library name sees it in the result. The hub keeps the plain description; body copy + and JSON-LD are unchanged. (#11203) - **Infrastructure failures no longer spend a pair's generation budget** — the 3-attempt cap in `impl-generate.yml` counted every failed run alike, so the Claude outage of 2026-09-02 (03:15–03:45 UTC, every run ending in `is_error:true` with an diff --git a/api/routers/seo.py b/api/routers/seo.py index 6b16d31fd5e..853955c0460 100644 --- a/api/routers/seo.py +++ b/api/routers/seo.py @@ -692,7 +692,15 @@ def _build_impl_html(spec, impl, code: str | None, image: str) -> str: title_esc = html.escape(spec.title) lib_name_esc = html.escape(lib_name) desc_esc = html.escape(spec.description or DEFAULT_DESCRIPTION) - meta_desc_esc = html.escape(_meta_description(spec.description or DEFAULT_DESCRIPTION)) + # The meta/OG description names the library first. Reusing the spec + # description verbatim gave every implementation page of a spec (up to 15) + # and its hub the same snippet — Bing Webmaster Tools flagged the catalogue + # for "too many pages with identical meta descriptions" (2026-09-02), and a + # searcher who typed "matplotlib funnel chart" saw nothing about matplotlib + # in the result. The visible body and the JSON-LD keep the plain text. + meta_desc_esc = html.escape( + _meta_description(f"{spec.title} in {lib_name} ({lang_name}): {spec.description or DEFAULT_DESCRIPTION}") + ) image_esc = html.escape(image, quote=True) hub_url = f"https://anyplot.ai/{spec.id}" page_url = f"{hub_url}/{language_id}/{impl.library_id}" diff --git a/docs/reference/seo.md b/docs/reference/seo.md index 51ae5f04b4a..826efd3e53e 100644 --- a/docs/reference/seo.md +++ b/docs/reference/seo.md @@ -248,6 +248,14 @@ Display names (Matplotlib, Makie.jl, Apache ECharts, …) are derived from `core/constants.py` (`LANGUAGES_METADATA` / `LIBRARIES_METADATA`) — never hand-maintained in the router. +The meta/OG description is trimmed to 155 characters (`_meta_description()`). +On an implementation page it starts with the page's own identity, +`{title} in {library} ({language}): {spec description}`, so the up to 15 +library pages of a spec and its hub no longer share one snippet — Bing +Webmaster Tools flagged that duplication in 2026-09 — and a searcher who +typed the library name sees it in the result. The hub keeps the plain spec +description; body copy and JSON-LD carry the full text on both. + ## What a crawler sees of the plot Two different images exist per implementation, and the bot page carries both, diff --git a/tests/unit/api/test_seo_helpers.py b/tests/unit/api/test_seo_helpers.py index 494937a599e..138a443a8ce 100644 --- a/tests/unit/api/test_seo_helpers.py +++ b/tests/unit/api/test_seo_helpers.py @@ -4,6 +4,7 @@ Directly tests the pure helper functions in api/routers/seo.py. """ +import html import json import re from datetime import datetime @@ -437,6 +438,24 @@ def test_jsonld_without_render_keeps_card_image(self) -> None: source = _extract_jsonld(page)["@graph"][1] assert source["image"] == "https://api.anyplot.ai/og/card.png" + def test_meta_description_names_the_library(self) -> None: + """Every implementation page of a spec used to carry the spec description + verbatim, so up to 16 pages shared one snippet (Bing flagged it, 2026-09).""" + mpl = _mock_impl("matplotlib", "python") + makie = _mock_impl("makie", "julia") + spec = _mock_spec([mpl, makie]) + mpl_page = _build_impl_html(spec, mpl, "code()", "https://api.anyplot.ai/og/card.png") + makie_page = _build_impl_html(spec, makie, "code()", "https://api.anyplot.ai/og/card.png") + mpl_match = re.search(r'{html.escape(spec.description)}

" in mpl_page + def test_no_code_no_pre_block(self) -> None: page = self._page(code=None) # No source block — the retrieval record has its own
 (class