feat(seo): show crawlers the plot, not the card it sits inside - #10482
Conversation
The bot page's only image was the 1200x630 og:image. In that card the
render is a thumbnail inside branding chrome — roughly a third of the
frame, cell labels barely legible. Right for a shared link, useless to an
assistant asked to show the plot, which is now a real use case rather
than a hypothetical one.
The body carries the actual render instead, as a <picture> with both
themes. Attribution is not the reason to prefer the card: every render's
own title reads "{spec} · {language} · {library} · anyplot.ai", so the
source travels with the image wherever it is embedded. og:image is
untouched — a shared link still gets the card it was designed for.
Sizes come from the _400/_800/_1200 derivatives the pipeline already
writes beside every render; their suffix is the true pixel width, checked
against the live files across square and wide plots in all four
languages, and every URL the markup emits was fetched and confirmed 200.
The full-size original is deliberately NOT in the srcset. Its width
varies per plot — 2400, 3200 and 4766 among those measured — so there is
no honest `w` descriptor for it, and a wrong one is worse than an absent
one. It gets its own link instead. src points at the 1200px variant, so
a consumer that ignores srcset gets something that looks right without
pulling a five-megapixel file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR improves the SEO “bot” implementation pages so crawlers (and assistants) see the actual plot render in the page body instead of only the branded 1200×630 social card (og:image). This aligns the bot HTML with the real “show me the plot” use case while keeping social preview behavior unchanged.
Changes:
- Add a
<picture>block (light + optional dark) to implementation bot pages, using the pipeline-generated_400/_800/_1200derivatives and linking to the full-resolution render. - Add unit tests covering
srcsetgeneration, defaultsrc, dark variant handling, and the full-resolution link. - Document the crawler-facing image behavior and add a changelog entry.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| api/routers/seo.py | Adds _sized_srcset() + _render_picture() and switches implementation bot pages to embed plot renders in the body. |
| tests/unit/api/test_seo_helpers.py | Adds tests for srcset derivation and <picture> HTML output behavior. |
| docs/reference/seo.md | Documents the difference between social card images and body-embedded plot renders for crawlers. |
| CHANGELOG.md | Records the behavior change under [Unreleased]. |
Suppressed comments (1)
api/routers/seo.py:513
- The fallback
<img>alt text is built fromspec.title/lib_nameviatitle_esc/lib_name_esc, but those are escaped withoutquote=True. A title containing quotes can break thealt="..."attribute context in the bot HTML. Escape the composed alt string withquote=Truein the fallback branch.
if impl.preview_url_light:
plot_img = _render_picture(impl, f"{title_esc} rendered with {lib_name_esc}")
else:
plot_img = f'<img src="{image_esc}" alt="{title_esc} rendered with {lib_name_esc}" width="1200" height="630" />'
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
The <picture> added a moment ago offers both themes, but only through a media query: that tells a browser which file to take and a reader nothing about which is which. And it says nothing at all about the interactive version. That version exists for 2,229 of 3,583 implementations — plotly, altair, bokeh, pygal, lets-plot and every JavaScript library — is publicly fetchable, returns text/html, and `preview_html` appeared nowhere in api/routers/seo.py. Two thirds of the catalogue had an interactive artefact that no machine reading the page could discover. The page now lists each asset in words: full-resolution render light and dark, interactive version light and dark, omitting whatever an implementation does not have so a static library is never advertised as interactive. All four URLs verified 200 against the live bucket. og:image stays. It is not redundant with the body render — it is the mechanism by which a shared link shows a picture at all, and removing it would leave social previews with nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Extended after a question from the owner: does the page note that light and dark exist, and that interactive libraries also produce The first was only implied — a The page now lists each asset in words — full-resolution light/dark, interactive light/dark — omitting what an implementation lacks, so a static library is never advertised as interactive. All four URLs verified 200 against the live bucket; three more tests, 1657 passing. One thing deliberately not removed, since it came up: |
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 (3)
api/routers/seo.py:534
- In the fallback branch (when
preview_url_lightis missing), the<img>altusestitle_esc/lib_name_esc, which were escaped withquote=False. If either contains a double quote, this produces invalid HTML and can allow attribute injection. Build the alt text from raw values and escape withquote=Truefor safe attribute output.
if impl.preview_url_light:
plot_img = _render_picture(impl, f"{title_esc} rendered with {lib_name_esc}")
else:
plot_img = f'<img src="{image_esc}" alt="{title_esc} rendered with {lib_name_esc}" width="1200" height="630" />'
api/routers/seo.py:465
- The
altattribute is interpolated without quote-escaping. Because callers currently buildaltfromtitle_esc/lib_name_esc(escaped withquote=False), a spec or library name containing a double quote (") could break the attribute and lead to HTML injection. Escaping viahtml.escapewould double-escape existing entities, so unescape first and then escape withquote=True.
f"<picture>{source}"
f'<img src="{light_default}" srcset="{html.escape(_sized_srcset(impl.preview_url_light), quote=True)}"'
f' alt="{alt}" />'
f"</picture>"
docs/reference/seo.md:277
- This section hard-codes the current count of implementations with interactive HTML ("2,229 of 3,583"). That number will drift as the catalog grows, which can make this reference doc inaccurate over time. Consider describing this as a large subset (and keep the list of libraries) without exact counts, or link to a statistic that is generated from the database.
The interactive HTML exists for **2,229 of 3,583 implementations** (plotly,
altair, bokeh, pygal, lets-plot and every JavaScript library), is publicly
fetchable, and was mentioned nowhere in the machine-facing page until this list
existed. A static library simply gets no such entry.
From the Copilot review, which is right about the gap and wrong about the fix. It suggested escaping alt inside the helper; the callers already escape, and html.escape defaults to quote=True, so doing it again turns a quoted spec title into a visible &quot; in the alt text. The real gap is that the contract was implicit. It is now stated, matching the one _render_bot_html already carries, and a test drives the real builder with the title 'Bar "Chart" & <b>' to prove the caller honours it and that nothing is double-escaped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Right about the gap, wrong about the fix — so I have done the first half. Escaping inside the helper would double-escape: What was genuinely missing is that the contract was implicit. It is now stated on |
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 (2)
docs/reference/seo.md:255
- The new section explains that implementation pages now embed the plot render as a
<picture>, but the “Per-page body content” table above still says the implementation page body contains a preview<img>. This makes the reference doc internally inconsistent; update the table row to reflect the new<picture>+ asset list behavior.
## What a crawler sees of the plot
Two different images exist per implementation, and the bot page carries both,
deliberately:
docs/reference/seo.md:277
- This reference doc hard-codes an exact count of implementations with interactive HTML ("2,229 of 3,583"). Because the catalog size changes over time, this will quickly become inaccurate; prefer a stable phrasing (for example “roughly two thirds”) or qualify the number with a date/source.
The interactive HTML exists for **2,229 of 3,583 implementations** (plotly,
altair, bokeh, pygal, lets-plot and every JavaScript library), is publicly
fetchable, and was mentioned nowhere in the machine-facing page until this list
existed. A static library simply gets no such entry.
The problem, seen rather than reasoned about
The bot page's only image was the
og:image. Fetching both and looking at them:api.anyplot.ai/og/heatmap-annotated/python/altair.png…/plot-light.pngThe card is right for a shared link. It is the wrong thing to hand an assistant asked to show the plot — which is now a real use case: a live test had Claude find
/heatmap-annotated/python/altair, read the source and return the image, and the image it could offer was the card.What changed
The body now carries the actual render as a
<picture>, both themes.og:imageis untouched.Three decisions worth stating
Attribution was not a reason to keep the card. I initially argued it was — the raw file sits on
storage.googleapis.com, so an embedded render would have no visible tie to anyplot. That was wrong: every render's own title reads{spec} · {language} · {library} · anyplot.ai. The source travels with the image.The full-size original is not in the
srcset. Its width varies per plot — 2400, 3200 and 4766 among those measured — so no honestwdescriptor exists for it. A wrong descriptor is worse than an absent one, so it gets a link instead.srcis the 1200px variant. A consumer that ignoressrcsetgets something that looks right without pulling a five-megapixel file.Verification
_400/_800/_1200derivatives exist for every implementation checked — five spec/library/language combinations across Python, JavaScript, R and Julia, 25/25 presentpytest tests/unit— 1654 passed, six new tests including that the full-size is absent from thesrcsetand reachable through its linkruff check+ruff format --check— clean🤖 Generated with Claude Code