forked from retoor/devplacepy
feat: add seo_meta service for AI-generated SEO metadata with CLI management and database layer
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:
@@ -5,6 +5,14 @@ import devplacepy.seo as seo
|
||||
import devplacepy.attachments as attach
|
||||
|
||||
|
||||
def _seo_request():
|
||||
return types.SimpleNamespace(
|
||||
base_url="https://x.test/",
|
||||
url=types.SimpleNamespace(path="/posts/abc"),
|
||||
query_params={},
|
||||
)
|
||||
|
||||
|
||||
def test_truncate_no_overflow():
|
||||
assert len(seo.truncate("x" * 200)) <= 160
|
||||
assert seo.truncate("short text") == "short text"
|
||||
@@ -76,3 +84,69 @@ def test_detect_mime_neutralizes_dangerous_types():
|
||||
assert attach._detect_mime(b"", "x.svg") == "application/octet-stream"
|
||||
assert attach._detect_mime(b"", "x.html") == "application/octet-stream"
|
||||
assert attach._detect_mime(b"", "x.png") == "image/png"
|
||||
|
||||
|
||||
def test_base_seo_context_emits_keywords_key():
|
||||
ctx = seo.base_seo_context(
|
||||
_seo_request(),
|
||||
title="A Post",
|
||||
description="Body text",
|
||||
keywords="python, sqlite",
|
||||
)
|
||||
assert "meta_keywords" in ctx
|
||||
assert ctx["meta_keywords"] == "python, sqlite"
|
||||
|
||||
|
||||
def test_base_seo_context_default_keywords_empty_without_target():
|
||||
ctx = seo.base_seo_context(
|
||||
_seo_request(), title="A Post", description="Body text"
|
||||
)
|
||||
assert ctx["meta_keywords"] == ""
|
||||
|
||||
|
||||
def test_base_seo_context_description_strips_markdown(monkeypatch):
|
||||
monkeypatch.setattr(seo, "_ready_seo_metadata", lambda target: None)
|
||||
ctx = seo.base_seo_context(
|
||||
_seo_request(),
|
||||
title="A Post",
|
||||
description="## Heading\n\nThis is **bold** body with `code`.",
|
||||
)
|
||||
assert "##" not in ctx["meta_description"]
|
||||
assert "**" not in ctx["meta_description"]
|
||||
assert "`" not in ctx["meta_description"]
|
||||
assert "bold" in ctx["meta_description"]
|
||||
|
||||
|
||||
def test_base_seo_context_plain_default_fills_fields_for_target(monkeypatch):
|
||||
monkeypatch.setattr(seo, "_ready_seo_metadata", lambda target: None)
|
||||
ctx = seo.base_seo_context(
|
||||
_seo_request(),
|
||||
title="Async Python Database Tooling",
|
||||
description="A **guide** to async python database tooling with sqlite.",
|
||||
seo_target=("post", "abc"),
|
||||
)
|
||||
assert ctx["meta_keywords"]
|
||||
assert "python" in ctx["meta_keywords"]
|
||||
assert ctx["meta_description"]
|
||||
assert "**" not in ctx["meta_description"]
|
||||
|
||||
|
||||
def test_base_seo_context_consumes_ready_metadata(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
seo,
|
||||
"_ready_seo_metadata",
|
||||
lambda target: {
|
||||
"seo_title": "Generated Title",
|
||||
"seo_description": "Generated clean description",
|
||||
"seo_keywords": "generated, metadata",
|
||||
},
|
||||
)
|
||||
ctx = seo.base_seo_context(
|
||||
_seo_request(),
|
||||
title="Raw **markdown** title",
|
||||
description="raw markdown body",
|
||||
seo_target=("post", "abc"),
|
||||
)
|
||||
assert "Generated Title" in ctx["page_title"]
|
||||
assert ctx["meta_description"] == "Generated clean description"
|
||||
assert ctx["meta_keywords"] == "generated, metadata"
|
||||
|
||||
Reference in New Issue
Block a user