Skip to content

Commit 3749b14

Browse files
feat(seo): enhance branded og:images with 2x3 grid and unified tagline (#3173)
## Summary - **2x3 Grid Collage**: Changed spec overview og:image from 3 to 6 images in a 2x3 grid layout, selecting top implementations by quality_score - **Unified Tagline**: Consistent `library-agnostic, ai-powered python plotting.` across all branded images, meta tags, and og:image - **Expanded Bot Detection**: From 13 to 27 bots including Reddit, Mastodon, Microsoft Teams, Snapchat, Viber, and more - **SEO Documentation**: New `docs/architecture/seo.md` with complete architecture overview - **Updated og-image.png**: New design matching the unified tagline ## Changes | File | Description | |------|-------------| | `api/routers/og_images.py` | Top 6 by quality_score for 2x3 grid | | `api/routers/seo.py` | Unified tagline | | `app/index.html` | Meta tags updated | | `app/nginx.conf` | 27 bots in 4 categories | | `app/public/og-image.png` | New design (compressed) | | `core/images.py` | 2x3 grid layout, new tagline | | `docs/architecture/seo.md` | **NEW** - SEO architecture docs | | `tests/unit/core/test_images.py` | Fix runpy warnings | ## Test plan - [x] All 732 unit tests passing - [x] No warnings - [x] Ruff check/format passing - [ ] Test og:image generation with real spec - [ ] Validate with social media debuggers (Twitter, Facebook, LinkedIn) Closes #3172 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 1b75341 commit 3749b14

File tree

14 files changed

+1154
-38
lines changed

14 files changed

+1154
-38
lines changed

CLAUDE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,8 @@ uv run pytest tests/unit/api/test_routers.py::test_get_specs
165165

166166
**Both linting and formatting must pass for CI.**
167167

168+
**IMPORTANT: Always run `uv run ruff check <files> && uv run ruff format <files>` on changed files BEFORE every commit!**
169+
168170
```bash
169171
# Linting (required for CI)
170172
uv run ruff check .
@@ -174,6 +176,9 @@ uv run ruff check . --fix
174176

175177
# Formatting (required for CI)
176178
uv run ruff format .
179+
180+
# Before committing - always run both on changed files:
181+
uv run ruff check <files> && uv run ruff format <files>
177182
```
178183

179184
### Frontend Development

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
[![Ruff](https://github.com/MarkusNeusinger/pyplots/actions/workflows/ci-lint.yml/badge.svg?branch=main)](https://github.com/MarkusNeusinger/pyplots/actions/workflows/ci-lint.yml)
99
[![codecov](https://codecov.io/github/MarkusNeusinger/pyplots/graph/badge.svg?token=4EGPSHH0H0)](https://codecov.io/github/MarkusNeusinger/pyplots)
1010

11-
> library-agnostic, ai-powered python plotting examples. automatically generated, tested, and maintained.
11+
> library-agnostic, ai-powered python plotting examples.
1212
1313
---
1414

api/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
download_router,
2727
health_router,
2828
libraries_router,
29+
og_images_router,
2930
plots_router,
3031
proxy_router,
3132
seo_router,
@@ -129,6 +130,7 @@ async def add_cache_headers(request: Request, call_next):
129130
app.include_router(plots_router)
130131
app.include_router(download_router)
131132
app.include_router(seo_router)
133+
app.include_router(og_images_router)
132134
app.include_router(proxy_router)
133135

134136

api/routers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from api.routers.download import router as download_router
44
from api.routers.health import router as health_router
55
from api.routers.libraries import router as libraries_router
6+
from api.routers.og_images import router as og_images_router
67
from api.routers.plots import router as plots_router
78
from api.routers.proxy import router as proxy_router
89
from api.routers.seo import router as seo_router
@@ -14,6 +15,7 @@
1415
"download_router",
1516
"health_router",
1617
"libraries_router",
18+
"og_images_router",
1719
"plots_router",
1820
"proxy_router",
1921
"seo_router",

api/routers/og_images.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""OG Image endpoints for branded social media preview images."""
2+
3+
import asyncio
4+
5+
import httpx
6+
from fastapi import APIRouter, Depends, HTTPException
7+
from fastapi.responses import Response
8+
from sqlalchemy.ext.asyncio import AsyncSession
9+
10+
from api.cache import cache_key, get_cache, set_cache
11+
from api.dependencies import optional_db
12+
from core.database import SpecRepository
13+
from core.images import create_branded_og_image, create_og_collage
14+
15+
16+
router = APIRouter(prefix="/og", tags=["og-images"])
17+
18+
# Cache TTL for generated images (1 hour)
19+
OG_IMAGE_CACHE_TTL = 3600
20+
21+
22+
async def _fetch_image(url: str) -> bytes:
23+
"""Fetch an image from a URL."""
24+
async with httpx.AsyncClient(timeout=30.0) as client:
25+
response = await client.get(url)
26+
response.raise_for_status()
27+
return response.content
28+
29+
30+
@router.get("/{spec_id}/{library}.png")
31+
async def get_branded_impl_image(
32+
spec_id: str, library: str, db: AsyncSession | None = Depends(optional_db)
33+
) -> Response:
34+
"""Get a branded OG image for an implementation.
35+
36+
Returns a 1200x630 PNG with pyplots.ai header and the plot image.
37+
"""
38+
# Check cache first
39+
key = cache_key("og", spec_id, library)
40+
cached = get_cache(key)
41+
if cached:
42+
return Response(content=cached, media_type="image/png", headers={"Cache-Control": "public, max-age=3600"})
43+
44+
if db is None:
45+
raise HTTPException(status_code=503, detail="Database not available")
46+
47+
repo = SpecRepository(db)
48+
spec = await repo.get_by_id(spec_id)
49+
if not spec:
50+
raise HTTPException(status_code=404, detail="Spec not found")
51+
52+
# Find the implementation
53+
impl = next((i for i in spec.impls if i.library_id == library), None)
54+
if not impl or not impl.preview_url:
55+
raise HTTPException(status_code=404, detail="Implementation not found")
56+
57+
try:
58+
# Fetch the original plot image
59+
image_bytes = await _fetch_image(impl.preview_url)
60+
61+
# Create branded image
62+
branded_bytes = create_branded_og_image(image_bytes, spec_id=spec_id, library=library)
63+
64+
# Cache the result
65+
set_cache(key, branded_bytes, ttl=OG_IMAGE_CACHE_TTL)
66+
67+
return Response(
68+
content=branded_bytes, media_type="image/png", headers={"Cache-Control": "public, max-age=3600"}
69+
)
70+
71+
except httpx.HTTPError as e:
72+
raise HTTPException(status_code=502, detail=f"Failed to fetch image: {e}") from e
73+
74+
75+
@router.get("/{spec_id}.png")
76+
async def get_spec_collage_image(spec_id: str, db: AsyncSession | None = Depends(optional_db)) -> Response:
77+
"""Get a collage OG image for a spec (showing top 6 implementations by quality).
78+
79+
Returns a 1200x630 PNG with pyplots.ai branding and a 2x3 grid of implementations,
80+
sorted by quality_score descending.
81+
"""
82+
# Check cache first
83+
key = cache_key("og", spec_id, "collage")
84+
cached = get_cache(key)
85+
if cached:
86+
return Response(content=cached, media_type="image/png", headers={"Cache-Control": "public, max-age=3600"})
87+
88+
if db is None:
89+
raise HTTPException(status_code=503, detail="Database not available")
90+
91+
repo = SpecRepository(db)
92+
spec = await repo.get_by_id(spec_id)
93+
if not spec:
94+
raise HTTPException(status_code=404, detail="Spec not found")
95+
96+
# Get implementations with preview images
97+
impls_with_preview = [i for i in spec.impls if i.preview_url]
98+
if not impls_with_preview:
99+
raise HTTPException(status_code=404, detail="No implementations with previews")
100+
101+
# Sort by quality_score (descending) and take top 6 for 2x3 grid
102+
sorted_impls = sorted(
103+
impls_with_preview, key=lambda i: i.quality_score if i.quality_score is not None else 0, reverse=True
104+
)
105+
selected_impls = sorted_impls[:6]
106+
107+
try:
108+
# Fetch all images in parallel for better performance
109+
images = list(await asyncio.gather(*[_fetch_image(impl.preview_url) for impl in selected_impls]))
110+
labels = [f"{spec_id} · {impl.library_id}" for impl in selected_impls]
111+
112+
# Create collage
113+
collage_bytes = create_og_collage(images, labels=labels)
114+
115+
# Cache the result
116+
set_cache(key, collage_bytes, ttl=OG_IMAGE_CACHE_TTL)
117+
118+
return Response(
119+
content=collage_bytes, media_type="image/png", headers={"Cache-Control": "public, max-age=3600"}
120+
)
121+
122+
except httpx.HTTPError as e:
123+
raise HTTPException(status_code=502, detail=f"Failed to fetch images: {e}") from e

api/routers/seo.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,7 @@
3737
</html>"""
3838

3939
DEFAULT_IMAGE = "https://pyplots.ai/og-image.png"
40-
DEFAULT_DESCRIPTION = (
41-
"Library-agnostic, AI-powered Python plotting examples. Automatically generated, tested, and maintained."
42-
)
40+
DEFAULT_DESCRIPTION = "library-agnostic, ai-powered python plotting."
4341

4442

4543
@router.get("/sitemap.xml")
@@ -115,7 +113,7 @@ async def seo_catalog():
115113

116114
@router.get("/seo-proxy/{spec_id}")
117115
async def seo_spec_overview(spec_id: str, db: AsyncSession | None = Depends(optional_db)):
118-
"""Bot-optimized spec overview page with correct og:tags."""
116+
"""Bot-optimized spec overview page with collage og:image."""
119117
if db is None:
120118
# Fallback when DB unavailable
121119
return HTMLResponse(
@@ -132,19 +130,23 @@ async def seo_spec_overview(spec_id: str, db: AsyncSession | None = Depends(opti
132130
if not spec:
133131
raise HTTPException(status_code=404, detail="Spec not found")
134132

133+
# Use collage og:image if implementations exist, otherwise default
134+
has_previews = any(i.preview_url for i in spec.impls)
135+
image = f"https://api.pyplots.ai/og/{spec_id}.png" if has_previews else DEFAULT_IMAGE
136+
135137
return HTMLResponse(
136138
BOT_HTML_TEMPLATE.format(
137139
title=f"{html.escape(spec.title)} | pyplots.ai",
138140
description=html.escape(spec.description or DEFAULT_DESCRIPTION),
139-
image=DEFAULT_IMAGE,
141+
image=html.escape(image, quote=True),
140142
url=f"https://pyplots.ai/{html.escape(spec_id)}",
141143
)
142144
)
143145

144146

145147
@router.get("/seo-proxy/{spec_id}/{library}")
146148
async def seo_spec_implementation(spec_id: str, library: str, db: AsyncSession | None = Depends(optional_db)):
147-
"""Bot-optimized spec implementation page with dynamic og:image from preview_url."""
149+
"""Bot-optimized spec implementation page with branded og:image."""
148150
if db is None:
149151
# Fallback when DB unavailable
150152
return HTMLResponse(
@@ -163,7 +165,8 @@ async def seo_spec_implementation(spec_id: str, library: str, db: AsyncSession |
163165

164166
# Find the implementation for this library
165167
impl = next((i for i in spec.impls if i.library_id == library), None)
166-
image = impl.preview_url if impl and impl.preview_url else DEFAULT_IMAGE
168+
# Use branded og:image endpoint if implementation has preview
169+
image = f"https://api.pyplots.ai/og/{spec_id}/{library}.png" if impl and impl.preview_url else DEFAULT_IMAGE
167170

168171
return HTMLResponse(
169172
BOT_HTML_TEMPLATE.format(

app/index.html

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<meta charset="UTF-8" />
55
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7-
<meta name="description" content="library-agnostic, ai-powered python plotting examples. automatically generated, tested, and maintained." />
7+
<meta name="description" content="library-agnostic, ai-powered python plotting." />
88
<meta name="keywords" content="python, plotting, matplotlib, seaborn, plotly, bokeh, altair, plotnine, pygal, highcharts, letsplot, data visualization, charts, graphs, ai-generated, code examples" />
99
<title>pyplots.ai</title>
1010

@@ -15,14 +15,14 @@
1515
<meta property="og:type" content="website" />
1616
<meta property="og:url" content="https://pyplots.ai/" />
1717
<meta property="og:title" content="pyplots.ai" />
18-
<meta property="og:description" content="library-agnostic, ai-powered python plotting examples. automatically generated, tested, and maintained." />
18+
<meta property="og:description" content="library-agnostic, ai-powered python plotting." />
1919
<meta property="og:image" content="https://pyplots.ai/og-image.png" />
2020
<meta property="og:site_name" content="pyplots.ai" />
2121

2222
<!-- Twitter Card -->
2323
<meta name="twitter:card" content="summary_large_image" />
2424
<meta name="twitter:title" content="pyplots.ai" />
25-
<meta name="twitter:description" content="library-agnostic, ai-powered python plotting examples. automatically generated, tested, and maintained." />
25+
<meta name="twitter:description" content="library-agnostic, ai-powered python plotting." />
2626
<meta name="twitter:image" content="https://pyplots.ai/og-image.png" />
2727
<!-- Preconnect to GCS for font loading -->
2828
<link rel="preconnect" href="https://storage.googleapis.com" crossorigin>
@@ -37,7 +37,7 @@
3737
"@type": "WebApplication",
3838
"name": "pyplots.ai",
3939
"url": "https://pyplots.ai",
40-
"description": "Library-agnostic, AI-powered Python plotting examples. Automatically generated, tested, and maintained.",
40+
"description": "library-agnostic, ai-powered python plotting.",
4141
"applicationCategory": "DeveloperApplication",
4242
"operatingSystem": "Any",
4343
"offers": {

app/nginx.conf

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,38 @@
11
# Bot detection for SEO - social media crawlers need pre-rendered meta tags
2+
# These bots cannot execute JavaScript, so we serve them pre-rendered HTML with og:tags
23
map $http_user_agent $is_bot {
34
default 0;
5+
# Social Media
46
~*twitterbot 1;
57
~*facebookexternalhit 1;
68
~*linkedinbot 1;
9+
~*pinterestbot 1;
10+
~*redditbot 1;
11+
~*tumblr 1;
12+
~*mastodon 1;
13+
# Messaging Apps
714
~*slackbot 1;
15+
~*discordbot 1;
816
~*telegrambot 1;
917
~*whatsapp 1;
1018
~*signal 1;
19+
~*viber 1;
20+
~*skypeuripreview 1;
21+
~*microsoft\ teams 1;
22+
~*snapchat 1;
23+
# Search Engines
1124
~*googlebot 1;
1225
~*bingbot 1;
13-
~*discordbot 1;
14-
~*pinterestbot 1;
26+
~*yandexbot 1;
27+
~*duckduckbot 1;
28+
~*baiduspider 1;
1529
~*applebot 1;
30+
# Link Preview Services
31+
~*embedly 1;
32+
~*quora\ link\ preview 1;
33+
~*outbrain 1;
34+
~*rogerbot 1;
35+
~*showyoubot 1;
1636
}
1737

1838
server {

app/public/og-image.png

-70.9 KB
Loading

0 commit comments

Comments
 (0)