Files

219 lines
12 KiB
PHP

<?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);
}
}
}