diff --git a/includes/Extensions/Batch2.php b/includes/Extensions/Batch2.php new file mode 100644 index 0000000..785fe4a --- /dev/null +++ b/includes/Extensions/Batch2.php @@ -0,0 +1,218 @@ + [ + '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); + } + } +}