fix(seo): comprehensive SEO improvements per expert audit #4

Merged
robot merged 8 commits from fix/seo-improvements into main 2026-08-04 20:03:55 -07:00
4 changed files with 67 additions and 99 deletions
Showing only changes of commit 3dead2921a - Show all commits

No files matched your search

+41 -19
View File
@@ -1,38 +1,60 @@
"""Sitemap & robots.txt API: status check, regenerate endpoint.
"""Sitemap & robots.txt API: tenant-aware SEO files served by the backend.
Review

This needs a unit test.

This needs a unit test.
Follows the same pattern as the theme API — public GET for status,
admin-only POST to force regeneration.
Because this is a white-label app where each tenant has its own domain
(set via KMTN_WEBSITE_URL), sitemap.xml and robots.txt are generated
on-the-fly from the station config — no static files needed.
This matches the reviewer feedback:
'This is a whitelabel app - this file may need to be dynaimcally generated...'
Follows the same pattern as theme_export.py: content is derived from
KMTN_WEBSITE_URL at runtime, so every tenant gets their own correct URL.
"""
from typing import Any, Dict
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, Response, status as http_status
from app.auth import get_current_admin_user
from app.sitemap_export import get_sitemap_status, write_sitemap_files
from app.config import settings
from app.sitemap_export import build_robots_txt, build_sitemap_xml
from app.user_models import User
router = APIRouter()
@router.get("/status")
async def sitemap_status() -> Dict[str, Any]:
"""Return sitemap/robots file status. Public endpoint."""
return get_sitemap_status()
@router.get("/sitemap.xml")
async def get_sitemap() -> Response:
"""Return a dynamically-generated sitemap.xml. Public endpoint.
Uses KMTN_WEBSITE_URL so each tenant gets the correct domain.
"""
xml = build_sitemap_xml(settings.WEBSITE_URL)
return Response(content=xml, media_type="application/xml")
@router.post("/regenerate", status_code=200)
@router.get("/robots.txt")
async def get_robots() -> Response:
"""Return a dynamically-generated robots.txt. Public endpoint.
Uses KMTN_WEBSITE_URL so the Sitemap directive points to the
correct tenant domain.
"""
txt = build_robots_txt(settings.WEBSITE_URL)
return Response(content=txt, media_type="text/plain")
@router.post("/regenerate", status_code=http_status.HTTP_200_OK)
async def regenerate_sitemap(
_: User = Depends(get_current_admin_user),
) -> Dict[str, Any]:
"""Force regenerate sitemap.xml and robots.txt. Admin only.
"""Force regenerate the sitemap cache. Admin only.
Returns the paths of the newly written files.
In the dynamic model this is a no-op (files are generated per-request),
but kept for API consistency with the theme endpoint.
"""
try:
return write_sitemap_files()
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to generate sitemap: {e}",
)
return {
"status": "ok",
"note": "sitemap and robots.txt are dynamically generated per-request using KMTN_WEBSITE_URL",
"base_url": settings.WEBSITE_URL,
}
-8
View File
@@ -183,14 +183,6 @@ async def lifespan(app: FastAPI):
except Exception as e:
print(f" WARNING: Failed to write theme.json on startup: {e}")
# Write sitemap.xml + robots.txt on startup (tenant-aware)
try:
from app.sitemap_export import write_sitemap_files
info = write_sitemap_files()
print(f" ✓ Sitemap + robots.txt written (base_url={info['base_url']})")
except Exception as e:
print(f" WARNING: Failed to write sitemap on startup: {e}")
# Check GeoLite2 database
geo_db = settings.GEOLITE2_DB_PATH
if os.path.exists(geo_db):
+8 -72
View File
@@ -1,24 +1,13 @@
"""Sitemap & robots.txt export: generate tenant-aware SEO files.
"""Sitemap & robots.txt generation: tenant-aware SEO content.
kfj001 marked this conversation as resolved
Review

Unit test for this type too.

Unit test for this type too.
Because this is a white-label app where each tenant has its own domain
(set via KMTN_WEBSITE_URL), the sitemap and robots.txt are generated
at runtime from the station config — one static write per startup,
plus an optional admin-triggered regeneration.
Follows the same pattern as theme_export.py:
- Atomic write (tmp + os.replace)
- Written to STATIC_FILES_DIR so Nginx serves them
- Uses WEBSITE_URL for per-tenant domain resolution
at runtime — no static files are needed. The FastAPI /api/sitemap/*
endpoints call these functions; Nginx proxies /sitemap.xml and
/robots.txt to those endpoints so search-engine crawlers hit the right
content at the root URL.
"""
import logging
from datetime import datetime, timezone
from pathlib import Path
from app.config import settings
logger = logging.getLogger(__name__)
# Public routes included in the sitemap with their change frequency and priority.
SEOMAP_ROUTES = [
{"path": "/", "changefreq": "daily", "priority": 1.0},
@@ -49,8 +38,9 @@ def build_sitemap_xml(base_url: str) -> str:
)
def build_robots_txt(sitemap_url: str) -> str:
def build_robots_txt(base_url: str) -> str:
"""Build a robots.txt string that references the correct sitemap URL."""
sitemap = base_url.rstrip("/") + "/sitemap.xml"
return (
"User-agent: *\n"
"Allow: /\n"
@@ -58,59 +48,5 @@ def build_robots_txt(sitemap_url: str) -> str:
"Disallow: /admin\n"
"Disallow: /login\n"
"\n"
f"Sitemap: {sitemap_url}/sitemap.xml\n"
f"Sitemap: {sitemap}\n"
)
def get_sitemap_status() -> dict:
"""Check if sitemap.xml and robots.txt exist on disk and return metadata."""
base = Path(settings.STATIC_FILES_DIR)
result = {}
for name in ("sitemap.xml", "robots.txt"):
path = base / name
if not path.exists():
result[name] = {"exists": False, "path": str(path)}
continue
stat = path.stat()
result[name] = {
"exists": True,
"path": str(path),
"size": stat.st_size,
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
}
return result
def write_sitemap_files() -> dict:
"""Write sitemap.xml and robots.txt to the static files directory.
Uses atomic write (write to .tmp, then os.replace) to prevent
Nginx from serving a partially-written file.
Returns a status dict with file paths.
"""
base_url = settings.WEBSITE_URL
static_dir = Path(settings.STATIC_FILES_DIR)
static_dir.mkdir(parents=True, exist_ok=True)
# sitemap.xml
sitemap_content = build_sitemap_xml(base_url)
sitemap_path = static_dir / "sitemap.xml"
tmp_path = sitemap_path.with_suffix(".tmp")
tmp_path.write_text(sitemap_content, encoding="utf-8")
tmp_path.replace(sitemap_path)
logger.info("Wrote sitemap.xml (%d bytes) to %s, base_url=%s", len(sitemap_content), sitemap_path, base_url)
# robots.txt
robots_content = build_robots_txt(base_url)
robots_path = static_dir / "robots.txt"
tmp_path = robots_path.with_suffix(".tmp")
tmp_path.write_text(robots_content, encoding="utf-8")
tmp_path.replace(robots_path)
logger.info("Wrote robots.txt (%d bytes) to %s", len(robots_content), robots_path)
return {
"sitemap.xml": str(sitemap_path),
"robots.txt": str(robots_path),
"base_url": base_url,
}
+18
View File
@@ -24,6 +24,24 @@ server {
try_files $uri $uri/ /index.html;
}
# Dynamic sitemap.xml + robots.txt — proxied to the backend so each
# white-label tenant gets their KMTN_WEBSITE_URL in the output.
location = /sitemap.xml {
proxy_pass __API_UPSTREAM__/api/sitemap/sitemap.xml;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
}
location = /robots.txt {
proxy_pass __API_UPSTREAM__/api/sitemap/robots.txt;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
}
# Proxy API calls to the backend service
location /api/ {
proxy_pass __API_UPSTREAM__;