Skip to main content
Glama
SEOWordPressClient.ts27.6 kB
/** * SEO WordPress Client * * Extended WordPress REST API client with enhanced SEO capabilities. * Provides specialized methods for SEO metadata management, schema markup, * and integration with popular WordPress SEO plugins like Yoast and RankMath. * * Features: * - SEO metadata retrieval and updates via REST API * - Integration with Yoast SEO and RankMath plugin APIs * - Schema markup management through WordPress custom fields * - Bulk SEO operations with progress tracking * - SEO-specific data normalization and validation * - Plugin-agnostic metadata handling * * @since 2.7.0 */ import { WordPressClient } from "./api.js"; import { LoggerFactory } from "@/utils/logger.js"; import { handleToolError } from "@/utils/error.js"; import type { WordPressPost, WordPressPage } from "@/types/wordpress.js"; import type { WordPressClientConfig } from "@/types/client.js"; import type { SchemaType } from "@/types/seo.js"; import type { SEOMetadata, SchemaMarkup } from "@/types/seo.js"; /** * WordPress Plugin interface for API responses */ interface WordPressPlugin { slug: string; status: "active" | "inactive"; name?: string; description?: string; version?: string; } /** * SEO plugin metadata field mappings */ interface SEOPluginFields { yoast: { title: string; description: string; focusKeyword: string; canonical: string; noindex: string; nofollow: string; schema: string; }; rankmath: { title: string; description: string; focusKeyword: string; canonical: string; robots: string; schema: string; }; seopress: { title: string; description: string; targetKeyword: string; canonical: string; robots: string; }; none: { title: string; description: string; focusKeyword: string; canonical: string; noindex: string; nofollow: string; schema: string; }; } /** * SEO data structure for API responses (flattened format) */ interface SEODataResponse { postId: number; plugin: "yoast" | "rankmath" | "seopress" | "none"; title: string | null; description: string | null; canonical: string | null; focusKeyword: string | null; openGraph: { title: string | null; description: string | null; }; twitter: { title: string | null; description: string | null; }; schema?: SchemaMarkup | undefined; raw: Record<string, unknown>; lastModified: string; success?: boolean; message?: string; } /** * Bulk SEO operation parameters */ interface BulkSEOParams { postIds: number[]; operation: "get" | "update"; metadata?: Partial<SEOMetadata>; batchSize?: number; progressCallback?: (processed: number, total: number) => void; } /** * Extended WordPress client with SEO capabilities */ export class SEOWordPressClient extends WordPressClient { private logger = LoggerFactory.tool("seo_client"); private detectedPlugin: "yoast" | "rankmath" | "seopress" | "none" = "none"; private pluginFields: SEOPluginFields; constructor(config?: Partial<WordPressClientConfig>) { super(config); // Define field mappings for different SEO plugins this.pluginFields = { yoast: { title: "_yoast_wpseo_title", description: "_yoast_wpseo_metadesc", focusKeyword: "_yoast_wpseo_focuskw", canonical: "_yoast_wpseo_canonical", noindex: "_yoast_wpseo_meta-robots-noindex", nofollow: "_yoast_wpseo_meta-robots-nofollow", schema: "_yoast_wpseo_schema_page_type", }, rankmath: { title: "rank_math_title", description: "rank_math_description", focusKeyword: "rank_math_focus_keyword", canonical: "rank_math_canonical_url", robots: "rank_math_robots", schema: "rank_math_rich_snippet", }, seopress: { title: "_seopress_titles_title", description: "_seopress_titles_desc", targetKeyword: "_seopress_analysis_target_kw", canonical: "_seopress_robots_canonical", robots: "_seopress_robots_index", }, none: { title: "meta_title", description: "meta_description", focusKeyword: "focus_keyword", canonical: "canonical_url", noindex: "robots_noindex", nofollow: "robots_nofollow", schema: "schema_markup", }, }; } /** * Initialize and detect SEO plugins */ async initializeSEO(): Promise<void> { this.logger.debug("Initializing SEO client and detecting plugins"); try { // Detect active SEO plugins await this.detectSEOPlugins(); this.logger.info("SEO client initialized", { detectedPlugin: this.detectedPlugin, }); } catch (_error) { this.logger.warn("SEO plugin detection failed, using generic approach", { _error: _error instanceof Error ? _error.message : String(_error), }); } } /** * Detect active SEO plugins on the WordPress site */ private async detectSEOPlugins(): Promise<void> { try { // Get list of installed plugins const plugins = await this.get("/wp/v2/plugins"); if (!Array.isArray(plugins)) { this.logger.debug("Could not retrieve plugins list"); return; } // Check for active SEO plugins in order of preference const activePlugins = (plugins as WordPressPlugin[]).filter((plugin) => plugin.status === "active"); // Check for Yoast SEO if (activePlugins.some((plugin) => plugin.slug === "wordpress-seo")) { this.detectedPlugin = "yoast"; return; } // Check for RankMath if (activePlugins.some((plugin) => plugin.slug === "seo-by-rank-math")) { this.detectedPlugin = "rankmath"; return; } // Check for SEOPress if (activePlugins.some((plugin) => plugin.slug === "wp-seopress")) { this.detectedPlugin = "seopress"; return; } this.logger.debug("No SEO plugins detected, using generic metadata approach"); } catch (_error) { this.logger.error("Plugin detection failed", { _error: _error instanceof Error ? _error.message : String(_error), }); } } /** * Get SEO metadata for a post or page */ async getSEOMetadata(postId: number, type: "post" | "page" = "post"): Promise<SEODataResponse> { this.logger.debug("Fetching SEO metadata", { postId, type, plugin: this.detectedPlugin }); try { // Get the post/page with meta fields const content = type === "post" ? await this.getPost(postId, "edit") // edit context includes meta : await this.getPage(postId, "edit"); if (!content) { throw new Error(`${type} with ID ${postId} not found`); } // Extract SEO metadata based on detected plugin const metadata = this.extractSEOMetadata(content); // Extract schema markup if available const schema = this.extractSchemaMarkup(content); // Get raw plugin data for debugging const raw = this.getRawPluginData(content); // For malformed plugin data (detected plugin but no actual data), return null for title/description const pluginDetected = this.detectedPlugin !== "none"; const pluginDataMalformed = pluginDetected && !this.hasPluginData(content); return { postId: postId, plugin: this.detectedPlugin, title: pluginDataMalformed ? null : metadata.title, description: pluginDataMalformed ? null : metadata.description, canonical: metadata.canonical || null, focusKeyword: metadata.focusKeyword || null, openGraph: metadata.openGraph || { title: null, description: null }, twitter: metadata.twitterCard ? { title: metadata.twitterCard.title || null, description: metadata.twitterCard.description || null, } : { title: null, description: null }, schema, raw, lastModified: content.modified || content.date || new Date().toISOString(), }; } catch (_error) { handleToolError(_error, "get SEO metadata", { postId, type }); throw _error; } } /** * Update SEO metadata for a post or page */ async updateSEOMetadata( postId: number, metadata: Partial<SEOMetadata>, type: "post" | "page" = "post", ): Promise<SEODataResponse> { this.logger.debug("Updating SEO metadata", { postId, type, plugin: this.detectedPlugin }); try { // Check if SEO plugin is available if (this.detectedPlugin === "none") { return { success: false, message: "No SEO plugin detected. Cannot update SEO metadata.", postId, plugin: this.detectedPlugin, title: null, description: null, focusKeyword: null, canonical: null, openGraph: { title: null, description: null }, twitter: { title: null, description: null }, schema: undefined, raw: {}, lastModified: new Date().toISOString(), }; } // Prepare meta fields based on detected plugin const metaFields = this.prepareSEOMetaFields(metadata); // Update the post/page with new meta fields const updateData = { id: postId, meta: metaFields, }; const _updatedContent = type === "post" ? await this.updatePost(updateData) : await this.updatePage(updateData); // Return updated SEO data return await this.getSEOMetadata(postId, type); } catch (_error) { handleToolError(_error, "update SEO metadata", { postId, type }); throw _error; } } /** * Bulk get SEO metadata for multiple posts */ async bulkGetSEOMetadata(params: BulkSEOParams): Promise<SEODataResponse[]> { this.logger.info("Starting bulk SEO metadata retrieval", { postCount: params.postIds.length, batchSize: params.batchSize || 10, }); const results: SEODataResponse[] = []; const batchSize = params.batchSize || 10; const total = params.postIds.length; // Process in batches for (let i = 0; i < params.postIds.length; i += batchSize) { const batch = params.postIds.slice(i, i + batchSize); const batchPromises = batch.map(async (postId) => { try { return await this.getSEOMetadata(postId); } catch (_error) { this.logger.warn("Failed to get SEO metadata for post", { postId, _error: _error instanceof Error ? _error.message : String(_error), }); return null; } }); const batchResults = await Promise.all(batchPromises); results.push(...(batchResults.filter((r) => r !== null) as SEODataResponse[])); // Report progress const processed = Math.min(i + batchSize, total); if (params.progressCallback) { params.progressCallback(processed, total); } // Small delay between batches to avoid overwhelming the server if (i + batchSize < params.postIds.length) { await new Promise((resolve) => setTimeout(resolve, 100)); } } this.logger.info("Bulk SEO metadata retrieval completed", { requested: total, successful: results.length, failed: total - results.length, }); return results; } /** * Bulk update SEO metadata for multiple posts */ async bulkUpdateSEOMetadata(params: BulkSEOParams & { metadata: Partial<SEOMetadata> }): Promise<SEODataResponse[]> { this.logger.info("Starting bulk SEO metadata update", { postCount: params.postIds.length, batchSize: params.batchSize || 5, // Smaller batches for updates }); const results: SEODataResponse[] = []; const batchSize = params.batchSize || 5; const total = params.postIds.length; // Process in smaller batches for updates for (let i = 0; i < params.postIds.length; i += batchSize) { const batch = params.postIds.slice(i, i + batchSize); const batchPromises = batch.map(async (postId) => { try { return await this.updateSEOMetadata(postId, params.metadata); } catch (_error) { this.logger.warn("Failed to update SEO metadata for post", { postId, _error: _error instanceof Error ? _error.message : String(_error), }); return null; } }); const batchResults = await Promise.all(batchPromises); results.push(...(batchResults.filter((r) => r !== null) as SEODataResponse[])); // Report progress const processed = Math.min(i + batchSize, total); if (params.progressCallback) { params.progressCallback(processed, total); } // Longer delay between update batches if (i + batchSize < params.postIds.length) { await new Promise((resolve) => setTimeout(resolve, 200)); } } this.logger.info("Bulk SEO metadata update completed", { requested: total, successful: results.length, failed: total - results.length, }); return results; } /** * Get all posts with SEO metadata for site audit */ async getAllPostsWithSEO( params: { postTypes?: string[]; maxPosts?: number; includePages?: boolean; } = {}, ): Promise<Array<(WordPressPost | WordPressPage) & { seoData?: SEODataResponse }>> { this.logger.debug("Fetching all posts with SEO data for audit", params); const maxPosts = params.maxPosts || 100; const results: Array<(WordPressPost | WordPressPage) & { seoData?: SEODataResponse }> = []; try { // Get posts const posts = await this.getPosts({ per_page: maxPosts, status: ["publish"], context: "edit", // Include meta fields }); // Get pages if requested let pages: WordPressPage[] = []; if (params.includePages) { pages = await this.getPages({ per_page: Math.max(20, Math.floor(maxPosts / 5)), // 20% allocation for pages status: ["publish"], context: "edit", }); } // Process posts for (const post of posts || []) { try { const seoData = await this.getSEOMetadata(post.id, "post"); results.push({ ...post, seoData }); } catch (_error) { // Include post without SEO data if metadata fetch fails results.push(post); this.logger.debug("Failed to get SEO data for post", { postId: post.id }); } } // Process pages for (const page of pages) { try { const seoData = await this.getSEOMetadata(page.id, "page"); results.push({ ...page, seoData }); } catch (_error) { // Include page without SEO data if metadata fetch fails results.push(page); this.logger.debug("Failed to get SEO data for page", { pageId: page.id }); } } this.logger.info("Retrieved posts with SEO data", { totalPosts: posts?.length || 0, totalPages: pages.length, withSEOData: results.filter((r) => r.seoData).length, }); return results; } catch (_error) { handleToolError(_error, "get all posts with SEO data", params); throw _error; } } /** * Extract SEO metadata from WordPress post/page object */ private extractSEOMetadata(content: WordPressPost | WordPressPage): SEOMetadata { const meta = (Array.isArray(content.meta) ? {} : content.meta || {}) as Record<string, unknown>; const fields = this.pluginFields[this.detectedPlugin]; // Extract basic metadata with plugin-specific field handling const focusKeyword = this.getPluginFocusKeyword(meta, fields); const canonical = this.extractMetaValue(meta, fields?.canonical); const metadata: SEOMetadata = { title: this.extractMetaValue(meta, fields?.title) || content.title?.rendered || "", description: this.extractMetaValue(meta, fields?.description) || content.excerpt?.rendered || "", ...(focusKeyword && { focusKeyword }), ...(canonical && { canonical }), }; // Extract robots directives based on plugin if (this.detectedPlugin === "yoast" && "noindex" in fields && "nofollow" in fields) { metadata.robots = { index: this.extractMetaValue(meta, fields.noindex) !== "1", follow: this.extractMetaValue(meta, fields.nofollow) !== "1", }; } else if (this.detectedPlugin === "rankmath" && "robots" in fields) { const robotsValue = this.extractMetaValue(meta, fields.robots) || ""; metadata.robots = { index: !robotsValue.includes("noindex"), follow: !robotsValue.includes("nofollow"), }; } // Extract OpenGraph and Twitter data based on plugin if (this.detectedPlugin === "yoast" && meta.yoast_head_json) { const yoastData = meta.yoast_head_json as Record<string, unknown>; metadata.openGraph = { title: (yoastData.og_title as string) || metadata.title, description: (yoastData.og_description as string) || metadata.description, type: content.type === "page" ? "website" : "article", url: content.link, }; const twitterTitle = yoastData.twitter_title as string; const twitterDescription = yoastData.twitter_description as string; metadata.twitterCard = { card: "summary", ...(twitterTitle && { title: twitterTitle }), ...(twitterDescription && { description: twitterDescription }), }; } else { // Default OpenGraph data metadata.openGraph = { title: metadata.title, description: metadata.description, type: content.type === "page" ? "website" : "article", url: content.link, }; } return metadata; } /** * Extract schema markup from post meta */ private extractSchemaMarkup(content: WordPressPost | WordPressPage): SchemaMarkup | undefined { const meta = (Array.isArray(content.meta) ? {} : content.meta || {}) as Record<string, unknown>; const fields = this.pluginFields[this.detectedPlugin]; const schemaData = this.getPluginSchemaData(meta, fields); if (!schemaData) { return undefined; } try { // Try to parse JSON-LD schema if (typeof schemaData === "string" && schemaData.startsWith("{")) { return JSON.parse(schemaData) as SchemaMarkup; } // Handle Yoast schema page types if (this.detectedPlugin === "yoast" && schemaData) { return { "@context": "https://schema.org", "@type": schemaData as SchemaType, }; } return undefined; } catch { return undefined; } } /** * Prepare meta fields for SEO metadata update */ private prepareSEOMetaFields(metadata: Partial<SEOMetadata>): Record<string, unknown> { const fields = this.pluginFields[this.detectedPlugin]; const metaFields: Record<string, unknown> = {}; // Map metadata to plugin-specific fields if (metadata.title && fields?.title) { metaFields[fields.title] = metadata.title; } if (metadata.description && fields?.description) { metaFields[fields.description] = metadata.description; } if (metadata.focusKeyword) { const focusKeywordField = this.getPluginFocusKeywordField(fields); if (focusKeywordField) { metaFields[focusKeywordField] = metadata.focusKeyword; } } if (metadata.canonical && fields?.canonical) { metaFields[fields.canonical] = metadata.canonical; } // Handle robots directives if (metadata.robots && this.detectedPlugin === "yoast" && "noindex" in fields && "nofollow" in fields) { if (fields.noindex) { metaFields[fields.noindex] = metadata.robots.index ? "0" : "1"; } if (fields.nofollow) { metaFields[fields.nofollow] = metadata.robots.follow ? "0" : "1"; } } else if (metadata.robots && this.detectedPlugin === "rankmath" && "robots" in fields) { const robotsArray: string[] = []; if (!metadata.robots.index) robotsArray.push("noindex"); if (!metadata.robots.follow) robotsArray.push("nofollow"); metaFields[fields.robots] = robotsArray.join(","); } return metaFields; } /** * Extract meta value with array handling */ private extractMetaValue(meta: Record<string, unknown>, fieldName?: string): string | null { if (!fieldName) { return null; } // For Yoast, check yoast_head_json first if (this.detectedPlugin === "yoast" && meta.yoast_head_json) { const yoastData = meta.yoast_head_json as Record<string, unknown>; // Map field names to yoast_head_json properties const yoastFieldMap: Record<string, string> = { _yoast_wpseo_title: "title", _yoast_wpseo_metadesc: "description", _yoast_wpseo_canonical: "canonical", _yoast_wpseo_focuskw: "focuskw", }; const yoastField = yoastFieldMap[fieldName]; if (yoastField && yoastData[yoastField]) { return yoastData[yoastField] as string; } } // Fallback to direct meta field lookup if (!meta[fieldName]) { return null; } const value = meta[fieldName]; // WordPress meta values can be arrays if (Array.isArray(value)) { return (value[0] as string) || null; } return (value as string) || null; } /** * Get plugin-specific focus keyword field */ private getPluginFocusKeyword(meta: Record<string, unknown>, fields: Record<string, string>): string | undefined { if (this.detectedPlugin === "seopress" && "targetKeyword" in fields) { return this.extractMetaValue(meta, fields.targetKeyword) || undefined; } else if ("focusKeyword" in fields) { return this.extractMetaValue(meta, fields.focusKeyword) || undefined; } return undefined; } /** * Get plugin-specific schema data field */ private getPluginSchemaData(meta: Record<string, unknown>, fields: Record<string, string>): string | undefined { if ("schema" in fields) { return this.extractMetaValue(meta, fields.schema) || undefined; } return undefined; } /** * Get plugin-specific focus keyword field name */ private getPluginFocusKeywordField(fields: Record<string, string>): string | undefined { if (this.detectedPlugin === "seopress" && "targetKeyword" in fields) { return fields.targetKeyword; } else if ("focusKeyword" in fields) { return fields.focusKeyword; } return undefined; } /** * Test SEO plugin integration */ async testSEOIntegration(): Promise<{ pluginDetected: string; canReadMetadata: boolean; canWriteMetadata: boolean; samplePostsWithSEO: number; errors?: string[]; }> { this.logger.info("Testing SEO integration"); const result = { pluginDetected: this.detectedPlugin, canReadMetadata: false, canWriteMetadata: false, samplePostsWithSEO: 0, errors: [] as string[], }; try { // Test reading metadata from recent posts const testPosts = await this.getPosts({ per_page: 5, status: ["publish"] }); if (testPosts && testPosts.length > 0) { let postsWithSEO = 0; for (const post of testPosts) { try { const seoData = await this.getSEOMetadata(post.id); if (seoData.title || seoData.description) { postsWithSEO++; } } catch (_error) { result.errors?.push(`Failed to read SEO data for post ${post.id}: ${(_error as Error).message}`); } } result.samplePostsWithSEO = postsWithSEO; result.canReadMetadata = true; } // Test writing metadata (if we have posts to test with) if (testPosts && testPosts.length > 0 && result.canReadMetadata) { try { const testPost = testPosts[0]; const originalSEO = await this.getSEOMetadata(testPost.id); // Make a small test update const testMetadata: Partial<SEOMetadata> = { description: (originalSEO.description || "") + " [TEST]", }; await this.updateSEOMetadata(testPost.id, testMetadata); // Restore original data await this.updateSEOMetadata(testPost.id, { description: originalSEO.description || "", }); result.canWriteMetadata = true; } catch (_error) { result.errors?.push(`Failed to write SEO data: ${(_error as Error).message}`); } } this.logger.info("SEO integration test completed", result); return result; } catch (_error) { result.errors?.push(`SEO integration test failed: ${(_error as Error).message}`); return result; } } /** * Get integration status for SEO features */ getIntegrationStatus(): { hasPlugin: boolean; plugin: string; canReadMetadata: boolean; canWriteMetadata: boolean; features: { metaTags: boolean; schema: boolean; socialMedia: boolean; xmlSitemap: boolean; breadcrumbs: boolean; }; } { const hasPlugin = this.detectedPlugin !== "none"; return { hasPlugin, plugin: this.detectedPlugin, canReadMetadata: hasPlugin, canWriteMetadata: hasPlugin, features: { metaTags: hasPlugin, schema: hasPlugin, socialMedia: hasPlugin, xmlSitemap: false, // Would need additional API calls breadcrumbs: false, }, }; } /** * Check if content has plugin-specific SEO data */ private hasPluginData(content: WordPressPost | WordPressPage): boolean { const meta = (Array.isArray(content.meta) ? {} : content.meta || {}) as Record<string, unknown>; switch (this.detectedPlugin) { case "yoast": // Check for yoast_head_json or individual meta fields return ( (meta.yoast_head_json !== null && meta.yoast_head_json !== undefined) || (meta._yoast_wpseo_title !== null && meta._yoast_wpseo_title !== undefined) ); case "rankmath": return meta.rank_math_title !== null && meta.rank_math_title !== undefined; case "seopress": return meta._seopress_titles_title !== null && meta._seopress_titles_title !== undefined; default: return false; } } /** * Get raw plugin data for debugging purposes */ private getRawPluginData(content: WordPressPost | WordPressPage): Record<string, unknown> { const meta = (Array.isArray(content.meta) ? {} : content.meta || {}) as Record<string, unknown>; // Return the raw plugin-specific data based on detected plugin switch (this.detectedPlugin) { case "yoast": return (meta.yoast_head_json as Record<string, unknown>) || {}; case "rankmath": // Extract RankMath specific fields const rankMathData: Record<string, unknown> = {}; Object.keys(meta).forEach((key) => { if (key.startsWith("rank_math_")) { rankMathData[key] = meta[key]; } }); return rankMathData; case "seopress": // Extract SEOPress specific fields const seopressData: Record<string, unknown> = {}; Object.keys(meta).forEach((key) => { if (key.startsWith("_seopress_")) { seopressData[key] = meta[key]; } }); return seopressData; default: return {}; } } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/docdyhr/mcp-wordpress'

If you have feedback or need assistance with the MCP directory API, please join our Discord server