feat(seo): enhance branded og:images with 2x3 grid and unified tagline#3173
feat(seo): enhance branded og:images with 2x3 grid and unified tagline#3173MarkusNeusinger merged 6 commits intomainfrom
Conversation
- Add pyplots.ai branded header to implementation og:images
- Generate collage og:images for spec overview pages (2x2 grid)
- On-demand image generation with 1-hour caching
- New endpoints: /og/{spec_id}/{library}.png and /og/{spec_id}.png
- Update SEO proxy to use new branded image URLs
- Optimize og-image.png from 1.2MB to 453KB
- Add 8 unit tests for og_images router
Closes #3172
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Change collage layout from 3 images to 2x3 grid (6 images) - Select top 6 implementations by quality_score for overview - Unify tagline to "library-agnostic, ai-powered python plotting." - Expand bot detection from 13 to 27 bots (Reddit, Mastodon, Teams, etc.) - Add comprehensive SEO architecture documentation - Update og-image.png with new design - Fix test warnings for runpy module execution 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Pull request overview
This PR enhances SEO capabilities by introducing branded og:images with a 2x3 grid collage layout for spec overview pages, unifying the tagline across all branded assets to "library-agnostic, ai-powered python plotting.", and expanding bot detection from 13 to 27 user agents. A new comprehensive SEO architecture document has been added to aid future maintenance.
Key Changes
- Branded OG Images: New
/og/{spec_id}.pngendpoint generates 2x3 grid collages of top 6 implementations by quality score, and/og/{spec_id}/{library}.pngfor individual branded implementation images - Unified Tagline: Simplified from "library-agnostic, ai-powered python plotting examples. automatically generated, tested, and maintained." to "library-agnostic, ai-powered python plotting." across all meta tags, README, and generated images
- Expanded Bot Detection: nginx now detects 27 bots (up from 13) across 4 categories: Social Media, Messaging Apps, Search Engines, and Link Preview Services
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
api/routers/og_images.py |
New router with endpoints for generating branded og:images and collages with 1-hour caching |
api/routers/seo.py |
Updated to use collage images for spec overview and branded images for implementations |
api/routers/__init__.py |
Added og_images_router export |
api/main.py |
Registered og_images_router |
core/images.py |
Added branded og:image generation and 2x3 collage creation with pyplots.ai branding |
app/nginx.conf |
Expanded bot detection from 13 to 27 user agents organized by category |
app/index.html |
Updated meta tags with unified tagline |
docs/architecture/seo.md |
New comprehensive SEO documentation covering bot detection, og:images, and sitemap |
tests/unit/core/test_images.py |
Fixed runpy warning with module cache cleanup fixture |
tests/unit/api/test_routers.py |
Added tests for new og_images router endpoints |
README.md |
Updated tagline to match unified branding |
| class TestOgImagesRouter: | ||
| """Tests for OG image generation endpoints.""" | ||
|
|
||
| def test_get_branded_impl_image_no_db(self, client: TestClient) -> None: | ||
| """Should return 503 when DB not available.""" | ||
| with patch(DB_CONFIG_PATCH, return_value=False): | ||
| response = client.get("/og/scatter-basic/matplotlib.png") | ||
| assert response.status_code == 503 | ||
|
|
||
| def test_get_branded_impl_image_spec_not_found(self, db_client) -> None: | ||
| """Should return 404 when spec not found.""" | ||
| client, _ = db_client | ||
|
|
||
| mock_spec_repo = MagicMock() | ||
| mock_spec_repo.get_by_id = AsyncMock(return_value=None) | ||
|
|
||
| with patch("api.routers.og_images.SpecRepository", return_value=mock_spec_repo): | ||
| response = client.get("/og/nonexistent/matplotlib.png") | ||
| assert response.status_code == 404 | ||
|
|
||
| def test_get_branded_impl_image_impl_not_found(self, db_client, mock_spec) -> None: | ||
| """Should return 404 when implementation not found.""" | ||
| client, _ = db_client | ||
|
|
||
| mock_spec_repo = MagicMock() | ||
| mock_spec_repo.get_by_id = AsyncMock(return_value=mock_spec) | ||
|
|
||
| with patch("api.routers.og_images.SpecRepository", return_value=mock_spec_repo): | ||
| # Request a library that doesn't exist in mock_spec | ||
| response = client.get("/og/scatter-basic/nonexistent.png") | ||
| assert response.status_code == 404 | ||
|
|
||
| def test_get_branded_impl_image_cached(self, db_client) -> None: | ||
| """Should return cached image when available.""" | ||
| client, _ = db_client | ||
|
|
||
| cached_bytes = b"fake png data" | ||
| with ( | ||
| patch("api.routers.og_images.get_cache", return_value=cached_bytes), | ||
| ): | ||
| response = client.get("/og/scatter-basic/matplotlib.png") | ||
| assert response.status_code == 200 | ||
| assert response.headers["content-type"] == "image/png" | ||
| assert response.content == cached_bytes | ||
|
|
||
| def test_get_spec_collage_no_db(self, client: TestClient) -> None: | ||
| """Should return 503 when DB not available.""" | ||
| with patch(DB_CONFIG_PATCH, return_value=False): | ||
| response = client.get("/og/scatter-basic.png") | ||
| assert response.status_code == 503 | ||
|
|
||
| def test_get_spec_collage_spec_not_found(self, db_client) -> None: | ||
| """Should return 404 when spec not found.""" | ||
| client, _ = db_client | ||
|
|
||
| mock_spec_repo = MagicMock() | ||
| mock_spec_repo.get_by_id = AsyncMock(return_value=None) | ||
|
|
||
| with patch("api.routers.og_images.SpecRepository", return_value=mock_spec_repo): | ||
| response = client.get("/og/nonexistent.png") | ||
| assert response.status_code == 404 | ||
|
|
||
| def test_get_spec_collage_no_previews(self, db_client) -> None: | ||
| """Should return 404 when no implementations have previews.""" | ||
| client, _ = db_client | ||
|
|
||
| mock_impl = MagicMock() | ||
| mock_impl.library_id = "matplotlib" | ||
| mock_impl.preview_url = None # No preview | ||
|
|
||
| mock_spec = MagicMock() | ||
| mock_spec.id = "scatter-basic" | ||
| mock_spec.impls = [mock_impl] | ||
|
|
||
| mock_spec_repo = MagicMock() | ||
| mock_spec_repo.get_by_id = AsyncMock(return_value=mock_spec) | ||
|
|
||
| with patch("api.routers.og_images.SpecRepository", return_value=mock_spec_repo): | ||
| response = client.get("/og/scatter-basic.png") | ||
| assert response.status_code == 404 | ||
|
|
||
| def test_get_spec_collage_cached(self, db_client) -> None: | ||
| """Should return cached collage when available.""" | ||
| client, _ = db_client | ||
|
|
||
| cached_bytes = b"fake collage png data" | ||
| with ( | ||
| patch("api.routers.og_images.get_cache", return_value=cached_bytes), | ||
| ): | ||
| response = client.get("/og/scatter-basic.png") | ||
| assert response.status_code == 200 | ||
| assert response.headers["content-type"] == "image/png" | ||
| assert response.content == cached_bytes | ||
|
|
There was a problem hiding this comment.
The test coverage for og_images router only covers error cases and cache hits, but doesn't test the successful path where images are actually fetched and processed. Consider adding tests that mock httpx.AsyncClient and the image processing functions to verify the full flow works correctly.
| for impl in selected_impls: | ||
| image_bytes = await _fetch_image(impl.preview_url) | ||
| images.append(image_bytes) | ||
| # Label format: "spec_id · library" like in og-image.png | ||
| labels.append(f"{spec_id} · {impl.library_id}") |
There was a problem hiding this comment.
The images are fetched sequentially in a for loop (lines 107-111), which could be slow when fetching 6 images. Consider using asyncio.gather to fetch all images concurrently, which would significantly improve the response time for collage generation.
| spec_id: str | None = None, | ||
| labels: list[str] | None = None, | ||
| ) -> Image.Image | bytes: | ||
| """Create a collage OG image from multiple plot images. | ||
|
|
||
| Creates a 2x3 grid (2 rows, 3 columns) with pyplots.ai branding: | ||
| - Large dominant logo and tagline at top | ||
| - 6 plots in 16:9 rounded cards arranged in 2 rows | ||
| - Labels below each card | ||
|
|
||
| Args: | ||
| images: List of plot images (paths, PIL Images, or bytes), up to 6 | ||
| output_path: If provided, save to this path | ||
| spec_id: Optional spec ID for subtitle |
There was a problem hiding this comment.
The spec_id parameter is documented as 'Optional spec ID for subtitle' but is never used in the function implementation. Either this parameter should be removed if it's not needed, or it should be used to display the spec ID somewhere in the collage (e.g., as a subtitle below the tagline).
| app.include_router(plots_router) | ||
| app.include_router(download_router) | ||
| app.include_router(seo_router) | ||
| app.include_router(og_images_router) |
There was a problem hiding this comment.
Consider adding HTTP Cache-Control headers for the /og/* endpoints in the cache middleware (around line 121 in the full file). Since the og_images endpoints return static images that are already cached internally for 1 hour, adding public cache headers would allow browsers and CDNs to cache them as well, reducing server load. For example: elif path.startswith("/og/"): response.headers["Cache-Control"] = "public, max-age=3600"
|
|
||
| Design matches og-image.png style: | ||
| - pyplots.ai logo at top | ||
| - Tagline "Beautiful Python plotting made easy." |
There was a problem hiding this comment.
The docstring mentions 'Beautiful Python plotting made easy.' as the tagline, but the actual implementation uses 'library-agnostic, ai-powered python plotting.' (line 394). The docstring should be updated to match the actual unified tagline being used in the code.
| - Tagline "Beautiful Python plotting made easy." | |
| - Tagline "library-agnostic, ai-powered python plotting." |
|
|
||
| Layout: | ||
| - pyplots.ai logo (centered, MonoLisa font 42px, weight 700) | ||
| - Tagline: "Beautiful Python plotting made easy." |
There was a problem hiding this comment.
The documentation states the tagline is 'Beautiful Python plotting made easy.' but this doesn't match the actual tagline used in the code ('library-agnostic, ai-powered python plotting.' on line 394 of core/images.py and throughout the PR). This should be updated to reflect the correct unified tagline.
| - Tagline: "Beautiful Python plotting made easy." | |
| - Tagline: "library-agnostic, ai-powered python plotting." |
| try: | ||
| # Fetch the original plot image | ||
| image_bytes = await _fetch_image(impl.preview_url) | ||
|
|
||
| # Create branded image | ||
| branded_bytes = create_branded_og_image(image_bytes, spec_id=spec_id, library=library) | ||
|
|
||
| # Cache the result | ||
| set_cache(key, branded_bytes, ttl=OG_IMAGE_CACHE_TTL) | ||
|
|
||
| return Response(content=branded_bytes, media_type="image/png") | ||
|
|
||
| except httpx.HTTPError as e: | ||
| raise HTTPException(status_code=502, detail=f"Failed to fetch image: {e}") from e |
There was a problem hiding this comment.
The image processing functions (create_branded_og_image) could raise PIL exceptions (e.g., UnidentifiedImageError, OSError) if the fetched bytes are not valid image data or if there are processing errors. Consider catching these exceptions and returning a 500 error with an appropriate message, similar to how httpx.HTTPError is caught.
| try: | ||
| # Fetch all images | ||
| images: list[bytes] = [] | ||
| labels: list[str] = [] | ||
| for impl in selected_impls: | ||
| image_bytes = await _fetch_image(impl.preview_url) | ||
| images.append(image_bytes) | ||
| # Label format: "spec_id · library" like in og-image.png | ||
| labels.append(f"{spec_id} · {impl.library_id}") | ||
|
|
||
| # Create collage | ||
| collage_bytes = create_og_collage(images, spec_id=spec_id, labels=labels) | ||
|
|
||
| # Cache the result | ||
| set_cache(key, collage_bytes, ttl=OG_IMAGE_CACHE_TTL) | ||
|
|
||
| return Response(content=collage_bytes, media_type="image/png") | ||
|
|
||
| except httpx.HTTPError as e: | ||
| raise HTTPException(status_code=502, detail=f"Failed to fetch images: {e}") from e |
There was a problem hiding this comment.
The image processing functions (create_og_collage) could raise PIL exceptions (e.g., UnidentifiedImageError, OSError) if any of the fetched bytes are not valid image data or if there are processing errors. Consider catching these exceptions and returning a 500 error with an appropriate message, similar to how httpx.HTTPError is caught.
- Use asyncio.gather for parallel image fetching (faster) - Remove unused spec_id parameter from create_og_collage - Add Cache-Control headers (public, max-age=3600) to all /og/ responses 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- test_get_branded_impl_image_success: generates branded image from DB - test_get_spec_collage_success: generates collage from DB with quality sorting - test Cache-Control headers on cached responses 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
| collage_bytes = create_og_collage(images, labels=labels) | ||
|
|
||
| # Cache the result | ||
| set_cache(key, collage_bytes, ttl=OG_IMAGE_CACHE_TTL) |
There was a problem hiding this comment.
The set_cache function does not accept a ttl parameter. According to api/cache.py, set_cache only accepts (key: str, value: Any). The TTL is configured globally in the cache settings. Remove the ttl parameter from this call.
| font = ImageFont.truetype(str(monolisa_path), size) | ||
| # Set variable font weight (MonoLisa supports 100-1000) | ||
| try: | ||
| font.set_variation_by_axes([weight]) |
There was a problem hiding this comment.
The set_variation_by_axes method expects a list of axis values in the font's native axis order, but this code passes a single weight value. For variable fonts, you should use set_variation_by_name with a dictionary like {'wght': weight} instead, or verify the correct axis order for the font. The current implementation will likely fail or produce incorrect results with variable fonts.
| font.set_variation_by_axes([weight]) | |
| font.set_variation_by_name({"wght": weight}) |
| - **TTL**: 1 hour (3600 seconds) | ||
| - **Cache Key**: `og:{spec_id}:{library}` or `og:{spec_id}:collage` | ||
| - **Storage**: In-memory API cache | ||
|
|
There was a problem hiding this comment.
The documentation states "TTL: 1 hour (3600 seconds)" but the current cache implementation (api/cache.py) uses a global TTL configured via settings, not per-item TTL. The OG_IMAGE_CACHE_TTL constant defined in og_images.py is unused since set_cache doesn't accept a ttl parameter. Update the documentation to clarify that caching uses the global TTL from settings, and that the 1-hour Cache-Control header is set for HTTP responses.
| - **TTL**: 1 hour (3600 seconds) | |
| - **Cache Key**: `og:{spec_id}:{library}` or `og:{spec_id}:collage` | |
| - **Storage**: In-memory API cache | |
| - **Cache TTL**: Uses the global in-memory API cache TTL configured in settings (not a fixed 1-hour per-item TTL) | |
| - **HTTP Cache-Control**: og:image responses set `Cache-Control: public, max-age=3600` (1 hour) | |
| - **Cache Key**: `og:{spec_id}:{library}` or `og:{spec_id}:collage` | |
| - **Storage**: In-memory API cache |
| # Cache TTL for generated images (1 hour) | ||
| OG_IMAGE_CACHE_TTL = 3600 |
There was a problem hiding this comment.
The OG_IMAGE_CACHE_TTL constant is defined but never actually used since the set_cache function doesn't accept a ttl parameter. This constant should either be removed, or if per-item TTL is desired, the cache implementation needs to be updated to support it.
| branded_bytes = create_branded_og_image(image_bytes, spec_id=spec_id, library=library) | ||
|
|
||
| # Cache the result | ||
| set_cache(key, branded_bytes, ttl=OG_IMAGE_CACHE_TTL) |
There was a problem hiding this comment.
The set_cache function does not accept a ttl parameter. According to api/cache.py, set_cache only accepts (key: str, value: Any). The TTL is configured globally in the cache settings. Remove the ttl parameter from this call and the one on line 116.
| set_cache(key, branded_bytes, ttl=OG_IMAGE_CACHE_TTL) | |
| set_cache(key, branded_bytes) |
Each E2E test now gets its own PostgreSQL schema (test_<uuid>), eliminating race conditions when tests try to DROP/CREATE tables concurrently. This allows tests to run in parallel without conflicts. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Summary
library-agnostic, ai-powered python plotting.across all branded images, meta tags, and og:imagedocs/architecture/seo.mdwith complete architecture overviewChanges
api/routers/og_images.pyapi/routers/seo.pyapp/index.htmlapp/nginx.confapp/public/og-image.pngcore/images.pydocs/architecture/seo.mdtests/unit/core/test_images.pyTest plan
Closes #3172
🤖 Generated with Claude Code