feat: add seo_meta service for AI-generated SEO metadata with CLI management and database layer
DevPlace CI / test (push) Failing after 2m13s
DevPlace CI / test (push) Failing after 2m13s
Implement a new `SeoMetaService` subservice that generates clean SEO title/description/keywords for published content items, distinct from the existing SEO diagnostics auditor. Add `seo_metadata` polymorphic table with soft-delete support, batch query methods, and usage tracking. Extend the CLI with `seo-meta prune` and `seo-meta clear` commands for job row lifecycle management. Wire `schedule_seo_meta_for_table` into content creation and editing flows in `content.py`. Document the new service in `AGENTS.md` and `README.md`, including the `extra_head` site setting for custom `<head>` injection.
This commit is contained in:
@@ -16,6 +16,8 @@ from devplacepy.dependencies import json_or_form
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
CLEARABLE_SETTINGS = {"extra_head"}
|
||||
|
||||
@router.get("/settings", response_class=HTMLResponse)
|
||||
async def admin_settings(request: Request):
|
||||
admin = require_admin(request)
|
||||
@@ -57,7 +59,7 @@ async def admin_settings_save(
|
||||
for key, value in data.model_dump().items():
|
||||
existing = settings.find_one(key=key)
|
||||
if existing:
|
||||
if value == "":
|
||||
if value == "" and key not in CLEARABLE_SETTINGS:
|
||||
continue
|
||||
old_value = existing.get("value")
|
||||
if str(old_value) == str(value):
|
||||
|
||||
@@ -156,7 +156,8 @@ async def gist_detail(request: Request, gist_slug: str):
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title=gist.get("title", "Gist"),
|
||||
description=(gist.get("description") or "")[:160],
|
||||
description=gist.get("description") or "",
|
||||
seo_target=("gist", gist["uid"]),
|
||||
og_type="article",
|
||||
og_image=first_image_url(gist, detail["attachments"]),
|
||||
breadcrumbs=[
|
||||
|
||||
@@ -140,7 +140,8 @@ async def issue_detail(request: Request, number: int):
|
||||
seo_ctx = issue_seo(
|
||||
request,
|
||||
title=f"Issue #{number}: {issue.get('title', '')}",
|
||||
description=issue.get("title", ""),
|
||||
description=body or issue.get("title", ""),
|
||||
seo_target=("issue", str(number)),
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
|
||||
@@ -124,7 +124,8 @@ async def news_detail_page(request: Request, news_slug: str):
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title=article.get("title", "News Article"),
|
||||
description=(article.get("description", "") or "")[:200],
|
||||
description=article.get("description", "") or "",
|
||||
seo_target=("news", article["uid"]),
|
||||
og_type="article",
|
||||
og_image=image_url or None,
|
||||
breadcrumbs=[
|
||||
|
||||
@@ -31,7 +31,6 @@ from devplacepy.seo import (
|
||||
site_url,
|
||||
website_schema,
|
||||
discussion_forum_posting,
|
||||
truncate,
|
||||
)
|
||||
from devplacepy.attachments import save_inline_image
|
||||
from devplacepy.models import PostForm, PostEditForm
|
||||
@@ -153,7 +152,8 @@ async def view_post(request: Request, post_slug: str):
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title=post.get("title") or "Post",
|
||||
description=truncate(post.get("content", ""), 160),
|
||||
description=post.get("content", ""),
|
||||
seo_target=("post", post["uid"]),
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Feed", "url": "/feed"},
|
||||
|
||||
@@ -182,7 +182,8 @@ async def project_detail(request: Request, project_slug: str):
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title=project.get("title", "Project"),
|
||||
description=project.get("description", "")[:160],
|
||||
description=project.get("description", ""),
|
||||
seo_target=("project", project["uid"]),
|
||||
robots=robots,
|
||||
og_image=first_image_url(project, detail["attachments"]),
|
||||
breadcrumbs=[
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from devplacepy.database import SEO_META_TYPES, get_seo_metadata
|
||||
from devplacepy.schemas import SeoMetaOut
|
||||
from devplacepy.services.seo_meta import defaults_for_target
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.seo import list_page_seo
|
||||
from devplacepy.utils import get_current_user
|
||||
@@ -54,3 +57,45 @@ async def tools_index(request: Request):
|
||||
"tools/index.html",
|
||||
{**seo_ctx, "request": request, "user": user, "tools": TOOLS},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/seo-meta/{target_type}/{target_uid}")
|
||||
async def seo_meta_status(request: Request, target_type: str, target_uid: str):
|
||||
if target_type not in SEO_META_TYPES:
|
||||
return JSONResponse(
|
||||
{"error": "unknown target type"}, status_code=400
|
||||
)
|
||||
if target_type == "project":
|
||||
from devplacepy.content import can_view_project
|
||||
from devplacepy.database import get_table
|
||||
|
||||
project = get_table("projects").find_one(uid=target_uid, deleted_at=None)
|
||||
if not can_view_project(dict(project) if project else None, get_current_user(request)):
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
row = get_seo_metadata(target_type, target_uid)
|
||||
if row:
|
||||
payload = {
|
||||
"uid": row.get("uid") or "",
|
||||
"target_type": target_type,
|
||||
"target_uid": target_uid,
|
||||
"seo_title": row.get("seo_title") or "",
|
||||
"seo_description": row.get("seo_description") or "",
|
||||
"seo_keywords": row.get("seo_keywords") or "",
|
||||
"status": row.get("status") or "ready",
|
||||
"source": row.get("source") or "ai",
|
||||
"generated_at": row.get("generated_at") or None,
|
||||
}
|
||||
else:
|
||||
defaults = defaults_for_target(target_type, target_uid)
|
||||
payload = {
|
||||
"uid": "",
|
||||
"target_type": target_type,
|
||||
"target_uid": target_uid,
|
||||
"seo_title": defaults["title"],
|
||||
"seo_description": defaults["description"],
|
||||
"seo_keywords": defaults["keywords"],
|
||||
"status": "pending",
|
||||
"source": "plain",
|
||||
"generated_at": None,
|
||||
}
|
||||
return JSONResponse(SeoMetaOut(**payload).model_dump())
|
||||
|
||||
Reference in New Issue
Block a user