Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
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
122 changes: 122 additions & 0 deletions api/routers/og_images.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""OG Image endpoints for branded social media preview images."""

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")

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")

except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Failed to fetch image: {e}") from e
Comment on lines +57 to +72
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 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.

Copilot uses AI. Check for mistakes.


@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")

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
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}")
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 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.

Copilot uses AI. Check for mistakes.

# 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)
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")

except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Failed to fetch images: {e}") from e
Comment on lines +107 to +123
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 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.

Copilot uses AI. Check for mistakes.
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
Loading