[ 'title' => '_yoast_wpseo_title', 'description' => '_yoast_wpseo_metadesc', 'focus_keyword' => '_yoast_wpseo_focuskw', 'canonical' => '_yoast_wpseo_canonical', 'og_title' => '_yoast_wpseo_opengraph-title', 'og_description' => '_yoast_wpseo_opengraph-description', 'og_image' => '_yoast_wpseo_opengraph-image', 'twitter_title' => '_yoast_wpseo_twitter-title', 'noindex' => '_yoast_wpseo_meta-robots-noindex', 'nofollow' => '_yoast_wpseo_meta-robots-nofollow', ], 'rankmath' => [ 'title' => 'rank_math_title', 'description' => 'rank_math_description', 'focus_keyword' => 'rank_math_focus_keyword', 'canonical' => 'rank_math_canonical_url', 'og_title' => 'rank_math_facebook_title', 'og_description' => 'rank_math_facebook_description', 'og_image' => 'rank_math_facebook_image', 'twitter_title' => 'rank_math_twitter_title', ], 'seopress' => [ 'title' => '_seopress_titles_title', 'description' => '_seopress_titles_desc', 'canonical' => '_seopress_robots_canonical', 'og_title' => '_seopress_social_fb_title', 'og_description' => '_seopress_social_fb_desc', 'og_image' => '_seopress_social_fb_img', 'twitter_title' => '_seopress_social_twitter_title', ], ]; public static function register($registry): void { $def = function (string $name, string $desc, array $props, array $required, string $scope, string $method) { $schema = ['type' => 'object', 'properties' => $props]; if ($required) $schema['required'] = $required; return ['name' => $name, 'description' => $desc, 'scope' => $scope, 'handler' => 'AskTamMCP\\Extensions\\Batch2::' . $method, 'inputSchema' => $schema]; }; $registry->register($def('seo_detect', 'Detect which SEO plugin is active (Yoast, RankMath, SEOPress, AIOSEO) and whether extended meta read/write is supported.', [], [], 'read', 'seoDetect')); $registry->register($def('seo_get_extended', 'Get extended SEO meta for a post: title, description, canonical, OG title/description/image, Twitter title, robots. Supports Yoast, RankMath, SEOPress.', ['post_id' => ['type' => 'integer']], ['post_id'], 'read', 'seoGet')); $registry->register($def('seo_update_extended', 'Update extended SEO meta on a post. Only supplied fields change. Supports Yoast, RankMath, SEOPress.', ['post_id' => ['type' => 'integer'], 'title' => ['type' => 'string'], 'description' => ['type' => 'string'], 'canonical' => ['type' => 'string'], 'og_title' => ['type' => 'string'], 'og_description' => ['type' => 'string'], 'og_image' => ['type' => 'string'], 'twitter_title' => ['type' => 'string'], 'noindex' => ['type' => 'boolean'], 'nofollow' => ['type' => 'boolean']], ['post_id'], 'write', 'seoUpdate')); $registry->register($def('seo_bulk_audit', 'Scan posts/pages for missing SEO title or meta description. Returns the offenders.', ['post_types' => ['type' => 'array', 'items' => ['type' => 'string'], 'default' => ['post', 'page']], 'per_page' => ['type' => 'integer', 'default' => 100]], [], 'read', 'seoAudit')); $registry->register($def('extract_page_text', 'Extract all human-readable text from a page: title, excerpt, post content, and every Elementor text node (with element_id + field) — for translation, audit, or rewriting.', ['post_id' => ['type' => 'integer']], ['post_id'], 'read', 'extractText')); } // ---- SEO ---- private static function detect(): string { if (defined('WPSEO_VERSION')) return 'yoast'; if (defined('RANK_MATH_VERSION')) return 'rankmath'; if (defined('SEOPRESS_VERSION') || function_exists('seopress_init')) return 'seopress'; if (defined('AIOSEO_VERSION')) return 'aioseo'; return 'none'; } public static function seoDetect(array $args): array { $p = self::detect(); return ['plugin' => $p, 'extended_supported' => isset(self::SEO[$p]), 'note' => $p === 'aioseo' ? 'AIOSEO stores SEO in a custom table; extended write not supported here.' : '']; } public static function seoGet(array $args): array { $id = (int) ($args['post_id'] ?? 0); if (!$id) throw new \RuntimeException('post_id is required.'); $p = self::detect(); if (!isset(self::SEO[$p])) return ['post_id' => $id, 'plugin' => $p, 'meta' => [], 'note' => 'No meta-based SEO plugin detected.']; $meta = []; foreach (self::SEO[$p] as $field => $key) { $val = get_post_meta($id, $key, true); if ($p === 'rankmath' && $field === 'focus_keyword' && is_array($val)) $val = implode(',', $val); $meta[$field] = $val; } if ($p === 'rankmath') { $robots = (array) get_post_meta($id, 'rank_math_robots', true); $meta['noindex'] = in_array('noindex', $robots, true); $meta['nofollow'] = in_array('nofollow', $robots, true); } elseif ($p === 'seopress') { $meta['noindex'] = get_post_meta($id, '_seopress_robots_index', true) === 'yes'; } else { $meta['noindex'] = (string) ($meta['noindex'] ?? '') === '1'; $meta['nofollow'] = (string) ($meta['nofollow'] ?? '') === '1'; } return ['post_id' => $id, 'plugin' => $p, 'meta' => $meta]; } public static function seoUpdate(array $args): array { $id = (int) ($args['post_id'] ?? 0); if (!$id) throw new \RuntimeException('post_id is required.'); $p = self::detect(); if (!isset(self::SEO[$p])) throw new \RuntimeException("Extended SEO update not supported for plugin: {$p}"); $map = self::SEO[$p]; $written = []; foreach (['title','description','canonical','og_title','og_description','og_image','twitter_title'] as $field) { if (array_key_exists($field, $args) && isset($map[$field])) { update_post_meta($id, $map[$field], sanitize_text_field((string) $args[$field])); $written[] = $field; } } // Robots $noindex = array_key_exists('noindex', $args) ? (bool) $args['noindex'] : null; $nofollow = array_key_exists('nofollow', $args) ? (bool) $args['nofollow'] : null; if ($noindex !== null || $nofollow !== null) { if ($p === 'rankmath') { $robots = (array) get_post_meta($id, 'rank_math_robots', true); $robots = array_values(array_diff($robots, ['noindex', 'nofollow'])); if ($noindex) $robots[] = 'noindex'; if ($nofollow) $robots[] = 'nofollow'; update_post_meta($id, 'rank_math_robots', $robots); } elseif ($p === 'seopress') { if ($noindex !== null) update_post_meta($id, '_seopress_robots_index', $noindex ? 'yes' : ''); } else { // yoast if ($noindex !== null) update_post_meta($id, $map['noindex'], $noindex ? '1' : '2'); if ($nofollow !== null) update_post_meta($id, $map['nofollow'], $nofollow ? '1' : '0'); } $written[] = 'robots'; } if (!$written) throw new \RuntimeException('No SEO fields supplied to update.'); return ['post_id' => $id, 'plugin' => $p, 'updated' => $written]; } public static function seoAudit(array $args): array { $types = $args['post_types'] ?? ['post', 'page']; $per = (int) ($args['per_page'] ?? 100); $p = self::detect(); if (!isset(self::SEO[$p])) return ['plugin' => $p, 'audited' => 0, 'issues' => [], 'note' => 'No meta-based SEO plugin detected.']; $titleKey = self::SEO[$p]['title']; $descKey = self::SEO[$p]['description']; $posts = get_posts(['post_type' => $types, 'post_status' => 'publish', 'posts_per_page' => $per, 'orderby' => 'date', 'order' => 'DESC']); $issues = []; foreach ($posts as $post) { $missing = []; if (trim((string) get_post_meta($post->ID, $titleKey, true)) === '') $missing[] = 'title'; if (trim((string) get_post_meta($post->ID, $descKey, true)) === '') $missing[] = 'description'; if ($missing) $issues[] = ['id' => $post->ID, 'type' => $post->post_type, 'post_title' => $post->post_title, 'missing' => $missing, 'edit' => get_edit_post_link($post->ID, '')]; } return ['plugin' => $p, 'audited' => count($posts), 'issues' => $issues, 'issue_count' => count($issues)]; } // ---- CONTENT EXTRACTION ---- private const TEXT_KEYS = ['title','editor','text','description','alert_title','alert_description', 'title_text','description_text','testimonial_content','testimonial_name','testimonial_job', 'caption','heading_title','sub_heading','button_text','alt','blockquote_content','text_next','text_prev']; private const REPEATERS = [ 'tabs' => ['tab_title','tab_content'], 'icon_list' => ['text'], 'price_list' => ['title','description'], 'slides' => ['heading','description','button_text'], 'icon_box' => ['title_text','description_text'], ]; public static function extractText(array $args): array { $id = (int) ($args['post_id'] ?? 0); $post = $id ? get_post($id) : null; if (!$post) throw new \RuntimeException("Post not found: {$id}"); $out = [ 'post_id' => $id, 'title' => $post->post_title, 'excerpt' => $post->post_excerpt, 'content_text' => trim(wp_strip_all_tags((string) $post->post_content)), 'elementor_texts' => [], ]; $raw = get_post_meta($id, '_elementor_data', true); if (!empty($raw)) { $data = json_decode($raw, true); if (is_array($data)) self::walk($data, $out['elementor_texts']); } $out['elementor_count'] = count($out['elementor_texts']); return $out; } private static function walk(array $elements, array &$acc): void { foreach ($elements as $el) { $eid = $el['id'] ?? ''; $wt = $el['widgetType'] ?? ($el['elType'] ?? ''); $settings = $el['settings'] ?? []; if (is_array($settings)) { foreach ($settings as $k => $v) { if (is_string($v) && in_array($k, self::TEXT_KEYS, true)) { $t = trim(wp_strip_all_tags($v)); if ($t !== '') $acc[] = ['element_id' => $eid, 'widget' => $wt, 'field' => $k, 'text' => $t]; } elseif (is_array($v) && isset(self::REPEATERS[$k])) { foreach ($v as $i => $row) { if (!is_array($row)) continue; foreach (self::REPEATERS[$k] as $sub) { if (isset($row[$sub]) && is_string($row[$sub])) { $t = trim(wp_strip_all_tags($row[$sub])); if ($t !== '') $acc[] = ['element_id' => $eid, 'widget' => $wt, 'field' => "{$k}[{$i}].{$sub}", 'text' => $t]; } } } } } } if (!empty($el['elements']) && is_array($el['elements'])) self::walk($el['elements'], $acc); } } }