feat(batch2): SEO deepening (Yoast/RankMath/SEOPress extended + audit) + page-text extraction
This commit is contained in:
@@ -0,0 +1,218 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Batch 2 — SEO deepening + content extraction (Pro-equivalent, net-new).
|
||||||
|
*
|
||||||
|
* Reimplements capabilities offered by the elementor-mcp Pro tier (SEO integrations,
|
||||||
|
* page-text extraction) as independent code against WordPress + the common SEO plugin
|
||||||
|
* meta schemas. No proprietary Pro source used. GPL-2.0-or-later. See NOTICE.md.
|
||||||
|
*
|
||||||
|
* Registered through the asktam_mcp_register_tools hook; core files untouched.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace AskTamMCP\Extensions;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
class Batch2 {
|
||||||
|
|
||||||
|
/** Meta-key maps per SEO plugin. null table-based plugins are reported, not written. */
|
||||||
|
private const SEO = [
|
||||||
|
'yoast' => [
|
||||||
|
'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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user