Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ uv run pytest tests/unit/api/test_routers.py::test_get_specs

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

**IMPORTANT: Always run `uv run ruff check <files> && uv run ruff format <files>` on changed files BEFORE every commit!**

```bash
# Linting (required for CI)
uv run ruff check .
Expand All @@ -174,6 +176,9 @@ uv run ruff check . --fix

# Formatting (required for CI)
uv run ruff format .

# Before committing - always run both on changed files:
uv run ruff check <files> && uv run ruff format <files>
```

### Frontend Development
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
[![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)
[![codecov](https://codecov.io/github/MarkusNeusinger/pyplots/graph/badge.svg?token=4EGPSHH0H0)](https://codecov.io/github/MarkusNeusinger/pyplots)

> library-agnostic, ai-powered python plotting examples. automatically generated, tested, and maintained.
> library-agnostic, ai-powered python plotting examples.

---

Expand Down
2 changes: 2 additions & 0 deletions api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
download_router,
health_router,
libraries_router,
og_images_router,
plots_router,
proxy_router,
seo_router,
Expand Down Expand Up @@ -129,6 +130,7 @@ async def add_cache_headers(request: Request, call_next):
app.include_router(plots_router)
app.include_router(download_router)
app.include_router(seo_router)
app.include_router(og_images_router)
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

Copilot uses AI. Check for mistakes.
app.include_router(proxy_router)


Expand Down
2 changes: 2 additions & 0 deletions api/routers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from api.routers.download import router as download_router
from api.routers.health import router as health_router
from api.routers.libraries import router as libraries_router
from api.routers.og_images import router as og_images_router
from api.routers.plots import router as plots_router
from api.routers.proxy import router as proxy_router
from api.routers.seo import router as seo_router
Expand All @@ -14,6 +15,7 @@
"download_router",
"health_router",
"libraries_router",
"og_images_router",
"plots_router",
"proxy_router",
"seo_router",
Expand Down
123 changes: 123 additions & 0 deletions api/routers/og_images.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""OG Image endpoints for branded social media preview images."""

import asyncio

import httpx
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession

from api.cache import cache_key, get_cache, set_cache
from api.dependencies import optional_db
from core.database import SpecRepository
from core.images import create_branded_og_image, create_og_collage


router = APIRouter(prefix="/og", tags=["og-images"])

# Cache TTL for generated images (1 hour)
OG_IMAGE_CACHE_TTL = 3600
Comment on lines +18 to +19
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.


async def _fetch_image(url: str) -> bytes:
"""Fetch an image from a URL."""
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url)
response.raise_for_status()
return response.content


@router.get("/{spec_id}/{library}.png")
async def get_branded_impl_image(
spec_id: str, library: str, db: AsyncSession | None = Depends(optional_db)
) -> Response:
"""Get a branded OG image for an implementation.

Returns a 1200x630 PNG with pyplots.ai header and the plot image.
"""
# Check cache first
key = cache_key("og", spec_id, library)
cached = get_cache(key)
if cached:
return Response(content=cached, media_type="image/png", headers={"Cache-Control": "public, max-age=3600"})

if db is None:
raise HTTPException(status_code=503, detail="Database not available")

repo = SpecRepository(db)
spec = await repo.get_by_id(spec_id)
if not spec:
raise HTTPException(status_code=404, detail="Spec not found")

# Find the implementation
impl = next((i for i in spec.impls if i.library_id == library), None)
if not impl or not impl.preview_url:
raise HTTPException(status_code=404, detail="Implementation not found")

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)
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
set_cache(key, branded_bytes, ttl=OG_IMAGE_CACHE_TTL)
set_cache(key, branded_bytes)

Copilot uses AI. Check for mistakes.

return Response(
content=branded_bytes, media_type="image/png", headers={"Cache-Control": "public, max-age=3600"}
)

except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Failed to fetch image: {e}") from e


@router.get("/{spec_id}.png")
async def get_spec_collage_image(spec_id: str, db: AsyncSession | None = Depends(optional_db)) -> Response:
"""Get a collage OG image for a spec (showing top 6 implementations by quality).

Returns a 1200x630 PNG with pyplots.ai branding and a 2x3 grid of implementations,
sorted by quality_score descending.
"""
# Check cache first
key = cache_key("og", spec_id, "collage")
cached = get_cache(key)
if cached:
return Response(content=cached, media_type="image/png", headers={"Cache-Control": "public, max-age=3600"})

if db is None:
raise HTTPException(status_code=503, detail="Database not available")

repo = SpecRepository(db)
spec = await repo.get_by_id(spec_id)
if not spec:
raise HTTPException(status_code=404, detail="Spec not found")

# Get implementations with preview images
impls_with_preview = [i for i in spec.impls if i.preview_url]
if not impls_with_preview:
raise HTTPException(status_code=404, detail="No implementations with previews")

# Sort by quality_score (descending) and take top 6 for 2x3 grid
sorted_impls = sorted(
impls_with_preview, key=lambda i: i.quality_score if i.quality_score is not None else 0, reverse=True
)
selected_impls = sorted_impls[:6]

try:
# Fetch all images in parallel for better performance
images = list(await asyncio.gather(*[_fetch_image(impl.preview_url) for impl in selected_impls]))
labels = [f"{spec_id} · {impl.library_id}" for impl in selected_impls]

# Create collage
collage_bytes = create_og_collage(images, labels=labels)

# Cache the result
set_cache(key, collage_bytes, ttl=OG_IMAGE_CACHE_TTL)
Copy link

Copilot AI Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

return Response(
content=collage_bytes, media_type="image/png", headers={"Cache-Control": "public, max-age=3600"}
)

except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Failed to fetch images: {e}") from e
17 changes: 10 additions & 7 deletions api/routers/seo.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,7 @@
</html>"""

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


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

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

# Use collage og:image if implementations exist, otherwise default
has_previews = any(i.preview_url for i in spec.impls)
image = f"https://api.pyplots.ai/og/{spec_id}.png" if has_previews else DEFAULT_IMAGE

return HTMLResponse(
BOT_HTML_TEMPLATE.format(
title=f"{html.escape(spec.title)} | pyplots.ai",
description=html.escape(spec.description or DEFAULT_DESCRIPTION),
image=DEFAULT_IMAGE,
image=html.escape(image, quote=True),
url=f"https://pyplots.ai/{html.escape(spec_id)}",
)
)


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

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

return HTMLResponse(
BOT_HTML_TEMPLATE.format(
Expand Down
8 changes: 4 additions & 4 deletions app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="library-agnostic, ai-powered python plotting examples. automatically generated, tested, and maintained." />
<meta name="description" content="library-agnostic, ai-powered python plotting." />
<meta name="keywords" content="python, plotting, matplotlib, seaborn, plotly, bokeh, altair, plotnine, pygal, highcharts, letsplot, data visualization, charts, graphs, ai-generated, code examples" />
<title>pyplots.ai</title>

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

<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="pyplots.ai" />
<meta name="twitter:description" content="library-agnostic, ai-powered python plotting examples. automatically generated, tested, and maintained." />
<meta name="twitter:description" content="library-agnostic, ai-powered python plotting." />
<meta name="twitter:image" content="https://pyplots.ai/og-image.png" />
<!-- Preconnect to GCS for font loading -->
<link rel="preconnect" href="https://storage.googleapis.com" crossorigin>
Expand All @@ -37,7 +37,7 @@
"@type": "WebApplication",
"name": "pyplots.ai",
"url": "https://pyplots.ai",
"description": "Library-agnostic, AI-powered Python plotting examples. Automatically generated, tested, and maintained.",
"description": "library-agnostic, ai-powered python plotting.",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "Any",
"offers": {
Expand Down
24 changes: 22 additions & 2 deletions app/nginx.conf
Original file line number Diff line number Diff line change
@@ -1,18 +1,38 @@
# Bot detection for SEO - social media crawlers need pre-rendered meta tags
# These bots cannot execute JavaScript, so we serve them pre-rendered HTML with og:tags
map $http_user_agent $is_bot {
default 0;
# Social Media
~*twitterbot 1;
~*facebookexternalhit 1;
~*linkedinbot 1;
~*pinterestbot 1;
~*redditbot 1;
~*tumblr 1;
~*mastodon 1;
# Messaging Apps
~*slackbot 1;
~*discordbot 1;
~*telegrambot 1;
~*whatsapp 1;
~*signal 1;
~*viber 1;
~*skypeuripreview 1;
~*microsoft\ teams 1;
~*snapchat 1;
# Search Engines
~*googlebot 1;
~*bingbot 1;
~*discordbot 1;
~*pinterestbot 1;
~*yandexbot 1;
~*duckduckbot 1;
~*baiduspider 1;
~*applebot 1;
# Link Preview Services
~*embedly 1;
~*quora\ link\ preview 1;
~*outbrain 1;
~*rogerbot 1;
~*showyoubot 1;
}

server {
Expand Down
Binary file modified app/public/og-image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading