- Fix Python formatting (sync no longer strips blank lines from .py files) - Remove test_community_build.py (tests private sync module) - Fix ruff warnings in test files - Add CODE_OF_CONDUCT.md - Add .github/PULL_REQUEST_TEMPLATE.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
620 lines
26 KiB
Python
620 lines
26 KiB
Python
"""SEO Handler - manages WordPress SEO plugin operations (Yoast/RankMath)"""
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
from plugins.wordpress.client import WordPressClient
|
|
|
|
|
|
def get_tool_specifications() -> list[dict[str, Any]]:
|
|
"""Return tool specifications for ToolGenerator"""
|
|
return [
|
|
# === SEO (Rank Math / Yoast) ===
|
|
{
|
|
"name": "get_post_seo",
|
|
"method_name": "get_post_seo",
|
|
"description": "Get SEO metadata for a WordPress post or page. Returns Rank Math or Yoast SEO fields including focus keyword, meta title, description, and social media settings. Requires SEO API Bridge plugin.",
|
|
"schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"post_id": {"type": "integer", "description": "Post or Page ID", "minimum": 1}
|
|
},
|
|
"required": ["post_id"],
|
|
},
|
|
"scope": "read",
|
|
},
|
|
{
|
|
"name": "get_product_seo",
|
|
"method_name": "get_product_seo",
|
|
"description": "Get SEO metadata for a WooCommerce product. Returns Rank Math or Yoast SEO fields including focus keyword, meta title, description, and social media settings. Requires SEO API Bridge plugin.",
|
|
"schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"product_id": {"type": "integer", "description": "Product ID", "minimum": 1}
|
|
},
|
|
"required": ["product_id"],
|
|
},
|
|
"scope": "read",
|
|
},
|
|
{
|
|
"name": "update_post_seo",
|
|
"method_name": "update_post_seo",
|
|
"description": "Update SEO metadata for a WordPress post or page. Supports both Rank Math and Yoast SEO fields. Automatically detects which plugin is active. Requires SEO API Bridge plugin.",
|
|
"schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"post_id": {
|
|
"type": "integer",
|
|
"description": "Post or Page ID to update",
|
|
"minimum": 1,
|
|
},
|
|
"focus_keyword": {
|
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
|
"description": "Primary focus keyword for SEO",
|
|
},
|
|
"seo_title": {
|
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
|
"description": "SEO meta title (appears in search results)",
|
|
},
|
|
"meta_description": {
|
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
|
"description": "SEO meta description (appears in search results)",
|
|
},
|
|
"additional_keywords": {
|
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
|
"description": "Additional keywords (comma-separated)",
|
|
},
|
|
"canonical_url": {
|
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
|
"description": "Canonical URL for this content",
|
|
},
|
|
"robots": {
|
|
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
|
"description": "Robots meta directives (e.g., ['noindex', 'nofollow'])",
|
|
},
|
|
"og_title": {
|
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
|
"description": "Open Graph title for Facebook",
|
|
},
|
|
"og_description": {
|
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
|
"description": "Open Graph description for Facebook",
|
|
},
|
|
"og_image": {
|
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
|
"description": "Open Graph image URL for Facebook",
|
|
},
|
|
"twitter_title": {
|
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
|
"description": "Twitter Card title",
|
|
},
|
|
"twitter_description": {
|
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
|
"description": "Twitter Card description",
|
|
},
|
|
"twitter_image": {
|
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
|
"description": "Twitter Card image URL",
|
|
},
|
|
},
|
|
"required": ["post_id"],
|
|
},
|
|
"scope": "write",
|
|
},
|
|
{
|
|
"name": "update_product_seo",
|
|
"method_name": "update_product_seo",
|
|
"description": "Update SEO metadata for a WooCommerce product. Same as update_post_seo but specifically for products. Requires SEO API Bridge plugin.",
|
|
"schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"product_id": {
|
|
"type": "integer",
|
|
"description": "Product ID to update",
|
|
"minimum": 1,
|
|
},
|
|
"focus_keyword": {
|
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
|
"description": "Primary focus keyword for SEO",
|
|
},
|
|
"seo_title": {
|
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
|
"description": "SEO meta title (appears in search results)",
|
|
},
|
|
"meta_description": {
|
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
|
"description": "SEO meta description (appears in search results)",
|
|
},
|
|
"additional_keywords": {
|
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
|
"description": "Additional keywords (comma-separated)",
|
|
},
|
|
"canonical_url": {
|
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
|
"description": "Canonical URL for this product",
|
|
},
|
|
"og_title": {
|
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
|
"description": "Open Graph title for Facebook",
|
|
},
|
|
"og_description": {
|
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
|
"description": "Open Graph description for Facebook",
|
|
},
|
|
"og_image": {
|
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
|
"description": "Open Graph image URL for Facebook",
|
|
},
|
|
"twitter_title": {
|
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
|
"description": "Twitter Card title",
|
|
},
|
|
"twitter_description": {
|
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
|
"description": "Twitter Card description",
|
|
},
|
|
"twitter_image": {
|
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
|
"description": "Twitter Card image URL",
|
|
},
|
|
},
|
|
"required": ["product_id"],
|
|
},
|
|
"scope": "write",
|
|
},
|
|
]
|
|
|
|
|
|
class SEOHandler:
|
|
"""Handle SEO-related operations for WordPress (Yoast SEO and RankMath)"""
|
|
|
|
def __init__(self, client: WordPressClient):
|
|
"""
|
|
Initialize SEO handler.
|
|
|
|
Args:
|
|
client: WordPress API client instance
|
|
"""
|
|
self.client = client
|
|
|
|
async def _check_seo_plugins(self) -> dict[str, Any]:
|
|
"""
|
|
Check if Rank Math or Yoast SEO is installed and SEO API Bridge is active.
|
|
|
|
Returns:
|
|
Dict with plugin status information
|
|
"""
|
|
try:
|
|
# First, try to use the new health check endpoint (v1.1.0+)
|
|
try:
|
|
status_result = await self.client.get(
|
|
"seo-api-bridge/v1/status", use_custom_namespace=True
|
|
)
|
|
|
|
if status_result and isinstance(status_result, dict):
|
|
# Successfully got status from dedicated endpoint
|
|
rank_math_info = status_result.get("seo_plugins", {}).get("rank_math", {})
|
|
yoast_info = status_result.get("seo_plugins", {}).get("yoast", {})
|
|
|
|
return {
|
|
"rank_math": {
|
|
"active": rank_math_info.get("active", False),
|
|
"version": rank_math_info.get("version"),
|
|
},
|
|
"yoast": {
|
|
"active": yoast_info.get("active", False),
|
|
"version": yoast_info.get("version"),
|
|
},
|
|
"api_bridge_active": True,
|
|
"api_bridge_version": status_result.get("version"),
|
|
"message": status_result.get("message", "SEO API Bridge is active"),
|
|
}
|
|
except Exception:
|
|
# Health check endpoint not available, fall back to old method
|
|
pass
|
|
|
|
# Fallback: Try to check posts for SEO meta fields
|
|
result = await self.client.get("posts", params={"per_page": 1})
|
|
|
|
# If no posts, try products
|
|
if not result or (isinstance(result, list) and len(result) == 0):
|
|
result = await self.client.get(
|
|
"products", params={"per_page": 1}, use_woocommerce=True
|
|
)
|
|
|
|
if not result or (isinstance(result, list) and len(result) == 0):
|
|
return {
|
|
"rank_math": {"active": False},
|
|
"yoast": {"active": False},
|
|
"api_bridge_active": False,
|
|
"message": "No posts or products available to check SEO plugin status. Please install SEO API Bridge v1.1.0+ or create content with SEO metadata.",
|
|
}
|
|
|
|
# Check if meta fields are present (indicates SEO API Bridge is active)
|
|
first_item = result[0] if isinstance(result, list) and len(result) > 0 else {}
|
|
meta = first_item.get("meta", {})
|
|
|
|
# Check for Rank Math fields
|
|
rank_math_active = any(
|
|
key in meta
|
|
for key in [
|
|
"rank_math_focus_keyword",
|
|
"rank_math_seo_title",
|
|
"rank_math_description",
|
|
]
|
|
)
|
|
|
|
# Check for Yoast SEO fields
|
|
yoast_active = any(
|
|
key in meta
|
|
for key in ["_yoast_wpseo_focuskw", "_yoast_wpseo_title", "_yoast_wpseo_metadesc"]
|
|
)
|
|
|
|
api_bridge_active = rank_math_active or yoast_active
|
|
|
|
return {
|
|
"rank_math": {"active": rank_math_active},
|
|
"yoast": {"active": yoast_active},
|
|
"api_bridge_active": api_bridge_active,
|
|
"message": (
|
|
"SEO API Bridge required. Please install and activate the plugin, then upgrade to v1.1.0+ for better detection."
|
|
if not api_bridge_active
|
|
else "SEO fields accessible via meta (legacy detection)"
|
|
),
|
|
}
|
|
except Exception as e:
|
|
return {
|
|
"rank_math": {"active": False},
|
|
"yoast": {"active": False},
|
|
"api_bridge_active": False,
|
|
"message": f"SEO plugin check failed: {str(e)}",
|
|
}
|
|
|
|
# === SEO METHODS ===
|
|
|
|
async def get_post_seo(self, post_id: int) -> str:
|
|
"""
|
|
Get SEO metadata for a post or page.
|
|
|
|
Args:
|
|
post_id: Post or Page ID
|
|
|
|
Returns:
|
|
JSON string with SEO metadata
|
|
"""
|
|
try:
|
|
result = await self.client.get(f"posts/{post_id}")
|
|
|
|
# Extract SEO meta fields
|
|
meta = result.get("meta", {})
|
|
|
|
# Check which SEO plugin is active
|
|
has_rank_math = "rank_math_focus_keyword" in meta
|
|
has_yoast = "_yoast_wpseo_focuskw" in meta
|
|
|
|
if not has_rank_math and not has_yoast:
|
|
return json.dumps(
|
|
{
|
|
"error": "SEO API Bridge plugin not detected",
|
|
"message": "Please install and activate the SEO API Bridge WordPress plugin.",
|
|
},
|
|
indent=2,
|
|
)
|
|
|
|
# Format SEO data
|
|
seo_data = {
|
|
"post_id": post_id,
|
|
"post_title": result.get("title", {}).get("rendered", ""),
|
|
"plugin_detected": "rank_math" if has_rank_math else "yoast",
|
|
}
|
|
|
|
if has_rank_math:
|
|
seo_data.update(
|
|
{
|
|
"focus_keyword": meta.get("rank_math_focus_keyword", ""),
|
|
"seo_title": meta.get("rank_math_seo_title", ""),
|
|
"meta_description": meta.get("rank_math_description", ""),
|
|
"additional_keywords": meta.get("rank_math_additional_keywords", ""),
|
|
"canonical_url": meta.get("rank_math_canonical_url", ""),
|
|
"robots": meta.get("rank_math_robots", []),
|
|
"breadcrumb_title": meta.get("rank_math_breadcrumb_title", ""),
|
|
"open_graph": {
|
|
"title": meta.get("rank_math_facebook_title", ""),
|
|
"description": meta.get("rank_math_facebook_description", ""),
|
|
"image": meta.get("rank_math_facebook_image", ""),
|
|
"image_id": meta.get("rank_math_facebook_image_id", ""),
|
|
},
|
|
"twitter": {
|
|
"title": meta.get("rank_math_twitter_title", ""),
|
|
"description": meta.get("rank_math_twitter_description", ""),
|
|
"image": meta.get("rank_math_twitter_image", ""),
|
|
"image_id": meta.get("rank_math_twitter_image_id", ""),
|
|
"card_type": meta.get("rank_math_twitter_card_type", ""),
|
|
},
|
|
}
|
|
)
|
|
elif has_yoast:
|
|
seo_data.update(
|
|
{
|
|
"focus_keyword": meta.get("_yoast_wpseo_focuskw", ""),
|
|
"seo_title": meta.get("_yoast_wpseo_title", ""),
|
|
"meta_description": meta.get("_yoast_wpseo_metadesc", ""),
|
|
"canonical_url": meta.get("_yoast_wpseo_canonical", ""),
|
|
"noindex": meta.get("_yoast_wpseo_meta-robots-noindex", ""),
|
|
"nofollow": meta.get("_yoast_wpseo_meta-robots-nofollow", ""),
|
|
"breadcrumb_title": meta.get("_yoast_wpseo_bctitle", ""),
|
|
"open_graph": {
|
|
"title": meta.get("_yoast_wpseo_opengraph-title", ""),
|
|
"description": meta.get("_yoast_wpseo_opengraph-description", ""),
|
|
"image": meta.get("_yoast_wpseo_opengraph-image", ""),
|
|
"image_id": meta.get("_yoast_wpseo_opengraph-image-id", ""),
|
|
},
|
|
"twitter": {
|
|
"title": meta.get("_yoast_wpseo_twitter-title", ""),
|
|
"description": meta.get("_yoast_wpseo_twitter-description", ""),
|
|
"image": meta.get("_yoast_wpseo_twitter-image", ""),
|
|
"image_id": meta.get("_yoast_wpseo_twitter-image-id", ""),
|
|
},
|
|
}
|
|
)
|
|
|
|
return json.dumps(seo_data, indent=2)
|
|
except Exception as e:
|
|
return json.dumps(
|
|
{
|
|
"error": str(e),
|
|
"message": f"Failed to get SEO data for post {post_id}: {str(e)}",
|
|
},
|
|
indent=2,
|
|
)
|
|
|
|
async def get_product_seo(self, product_id: int) -> str:
|
|
"""
|
|
Get SEO metadata for a WooCommerce product.
|
|
|
|
Uses SEO API Bridge endpoint for products.
|
|
|
|
Args:
|
|
product_id: Product ID
|
|
|
|
Returns:
|
|
JSON string with SEO metadata
|
|
"""
|
|
try:
|
|
# Use SEO API Bridge endpoint for products (same as update_product_seo)
|
|
result = await self.client.get(
|
|
f"seo-api-bridge/v1/products/{product_id}/seo", use_custom_namespace=True
|
|
)
|
|
|
|
return json.dumps(result, indent=2)
|
|
except Exception as e:
|
|
return json.dumps(
|
|
{
|
|
"error": str(e),
|
|
"message": f"Failed to get SEO data for product {product_id}: {str(e)}",
|
|
},
|
|
indent=2,
|
|
)
|
|
|
|
async def update_post_seo(
|
|
self,
|
|
post_id: int,
|
|
focus_keyword: str | None = None,
|
|
seo_title: str | None = None,
|
|
meta_description: str | None = None,
|
|
additional_keywords: str | None = None,
|
|
canonical_url: str | None = None,
|
|
robots: list[str] | None = None,
|
|
og_title: str | None = None,
|
|
og_description: str | None = None,
|
|
og_image: str | None = None,
|
|
twitter_title: str | None = None,
|
|
twitter_description: str | None = None,
|
|
twitter_image: str | None = None,
|
|
) -> str:
|
|
"""
|
|
Update SEO metadata for a post or page.
|
|
|
|
Automatically detects whether Rank Math or Yoast is active and uses appropriate field names.
|
|
|
|
Args:
|
|
post_id: Post or Page ID
|
|
focus_keyword: Primary focus keyword
|
|
seo_title: Meta title
|
|
meta_description: Meta description
|
|
additional_keywords: Additional keywords
|
|
canonical_url: Canonical URL
|
|
robots: Robots meta directives
|
|
og_title: Open Graph title
|
|
og_description: Open Graph description
|
|
og_image: Open Graph image URL
|
|
twitter_title: Twitter Card title
|
|
twitter_description: Twitter Card description
|
|
twitter_image: Twitter Card image URL
|
|
|
|
Returns:
|
|
JSON string with update result
|
|
"""
|
|
try:
|
|
# First check which SEO plugin is active
|
|
seo_check = await self._check_seo_plugins()
|
|
|
|
if not seo_check.get("api_bridge_active"):
|
|
return json.dumps(
|
|
{
|
|
"error": "SEO API Bridge plugin not detected",
|
|
"message": "Please install and activate the SEO API Bridge WordPress plugin.",
|
|
},
|
|
indent=2,
|
|
)
|
|
|
|
# Build meta object based on active plugin
|
|
meta = {}
|
|
|
|
if seo_check.get("rank_math", {}).get("active"):
|
|
# Use Rank Math field names
|
|
if focus_keyword is not None:
|
|
meta["rank_math_focus_keyword"] = focus_keyword
|
|
if seo_title is not None:
|
|
meta["rank_math_seo_title"] = seo_title
|
|
if meta_description is not None:
|
|
meta["rank_math_description"] = meta_description
|
|
if additional_keywords is not None:
|
|
meta["rank_math_additional_keywords"] = additional_keywords
|
|
if canonical_url is not None:
|
|
meta["rank_math_canonical_url"] = canonical_url
|
|
if robots is not None:
|
|
meta["rank_math_robots"] = robots
|
|
if og_title is not None:
|
|
meta["rank_math_facebook_title"] = og_title
|
|
if og_description is not None:
|
|
meta["rank_math_facebook_description"] = og_description
|
|
if og_image is not None:
|
|
meta["rank_math_facebook_image"] = og_image
|
|
if twitter_title is not None:
|
|
meta["rank_math_twitter_title"] = twitter_title
|
|
if twitter_description is not None:
|
|
meta["rank_math_twitter_description"] = twitter_description
|
|
if twitter_image is not None:
|
|
meta["rank_math_twitter_image"] = twitter_image
|
|
|
|
elif seo_check.get("yoast", {}).get("active"):
|
|
# Use Yoast field names
|
|
if focus_keyword is not None:
|
|
meta["_yoast_wpseo_focuskw"] = focus_keyword
|
|
if seo_title is not None:
|
|
meta["_yoast_wpseo_title"] = seo_title
|
|
if meta_description is not None:
|
|
meta["_yoast_wpseo_metadesc"] = meta_description
|
|
if canonical_url is not None:
|
|
meta["_yoast_wpseo_canonical"] = canonical_url
|
|
if og_title is not None:
|
|
meta["_yoast_wpseo_opengraph-title"] = og_title
|
|
if og_description is not None:
|
|
meta["_yoast_wpseo_opengraph-description"] = og_description
|
|
if og_image is not None:
|
|
meta["_yoast_wpseo_opengraph-image"] = og_image
|
|
if twitter_title is not None:
|
|
meta["_yoast_wpseo_twitter-title"] = twitter_title
|
|
if twitter_description is not None:
|
|
meta["_yoast_wpseo_twitter-description"] = twitter_description
|
|
if twitter_image is not None:
|
|
meta["_yoast_wpseo_twitter-image"] = twitter_image
|
|
|
|
# Update post with meta fields
|
|
data = {"meta": meta}
|
|
await self.client.post(f"posts/{post_id}", json_data=data)
|
|
|
|
# Read back saved values for confirmation
|
|
saved_seo_json = await self.get_post_seo(post_id)
|
|
saved_seo = json.loads(saved_seo_json)
|
|
|
|
response = {
|
|
"post_id": post_id,
|
|
"updated_fields": list(meta.keys()),
|
|
"message": f"SEO metadata updated successfully for post {post_id}",
|
|
"current_values": saved_seo,
|
|
}
|
|
|
|
return json.dumps(response, indent=2)
|
|
except Exception as e:
|
|
return json.dumps(
|
|
{
|
|
"error": str(e),
|
|
"message": f"Failed to update SEO data for post {post_id}: {str(e)}",
|
|
},
|
|
indent=2,
|
|
)
|
|
|
|
async def update_product_seo(
|
|
self,
|
|
product_id: int,
|
|
focus_keyword: str | None = None,
|
|
seo_title: str | None = None,
|
|
meta_description: str | None = None,
|
|
additional_keywords: str | None = None,
|
|
canonical_url: str | None = None,
|
|
og_title: str | None = None,
|
|
og_description: str | None = None,
|
|
og_image: str | None = None,
|
|
twitter_title: str | None = None,
|
|
twitter_description: str | None = None,
|
|
twitter_image: str | None = None,
|
|
) -> str:
|
|
"""
|
|
Update SEO metadata for a WooCommerce product.
|
|
|
|
Uses SEO API Bridge endpoint for products.
|
|
|
|
Args:
|
|
product_id: Product ID
|
|
focus_keyword: Primary focus keyword
|
|
seo_title: Meta title
|
|
meta_description: Meta description
|
|
additional_keywords: Additional keywords
|
|
canonical_url: Canonical URL
|
|
og_title: Open Graph title
|
|
og_description: Open Graph description
|
|
og_image: Open Graph image URL
|
|
twitter_title: Twitter Card title
|
|
twitter_description: Twitter Card description
|
|
twitter_image: Twitter Card image URL
|
|
|
|
Returns:
|
|
JSON string with update result
|
|
"""
|
|
try:
|
|
# Build request data with only provided fields
|
|
data = {}
|
|
|
|
if focus_keyword is not None:
|
|
data["focus_keyword"] = focus_keyword
|
|
if seo_title is not None:
|
|
data["seo_title"] = seo_title
|
|
if meta_description is not None:
|
|
data["meta_description"] = meta_description
|
|
if additional_keywords is not None:
|
|
data["additional_keywords"] = additional_keywords
|
|
if canonical_url is not None:
|
|
data["canonical_url"] = canonical_url
|
|
if og_title is not None:
|
|
data["og_title"] = og_title
|
|
if og_description is not None:
|
|
data["og_description"] = og_description
|
|
if og_image is not None:
|
|
data["og_image"] = og_image
|
|
if twitter_title is not None:
|
|
data["twitter_title"] = twitter_title
|
|
if twitter_description is not None:
|
|
data["twitter_description"] = twitter_description
|
|
if twitter_image is not None:
|
|
data["twitter_image"] = twitter_image
|
|
|
|
# Use SEO API Bridge endpoint for products
|
|
await self.client.post(
|
|
f"seo-api-bridge/v1/products/{product_id}/seo",
|
|
json_data=data,
|
|
use_custom_namespace=True,
|
|
)
|
|
|
|
# Read back saved values for confirmation
|
|
saved_seo_json = await self.get_product_seo(product_id)
|
|
saved_seo = json.loads(saved_seo_json)
|
|
|
|
response = {
|
|
"product_id": product_id,
|
|
"updated_fields": list(data.keys()),
|
|
"message": f"SEO metadata updated successfully for product {product_id}",
|
|
"current_values": saved_seo,
|
|
}
|
|
|
|
return json.dumps(response, indent=2)
|
|
except Exception as e:
|
|
return json.dumps(
|
|
{
|
|
"error": str(e),
|
|
"message": f"Failed to update SEO data for product {product_id}: {str(e)}",
|
|
},
|
|
indent=2,
|
|
)
|