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