| 1 |
<?php |
| 2 |
|
| 3 |
class Meow_MWSEO_MCP { |
| 4 |
// Bulk tools walk posts in chunks this size, releasing the object cache between them |
| 5 |
// so memory stays flat whatever the size of the site. |
| 6 |
const BULK_CHUNK_SIZE = 50; |
| 7 |
|
| 8 |
private $core; |
| 9 |
private $api; |
| 10 |
|
| 11 |
public function __construct( $core ) { |
| 12 |
$this->core = $core; |
| 13 |
|
| 14 |
// Initialize everything on 'init' to ensure options are loaded |
| 15 |
add_action( 'init', array( $this, 'init' ), 20 ); |
| 16 |
} |
| 17 |
|
| 18 |
public function init() { |
| 19 |
global $mwseo, $mwai; |
| 20 |
$this->api = $mwseo; |
| 21 |
|
| 22 |
// Only register MCP if enabled AND AI Engine is available |
| 23 |
if ( $this->core->get_option( 'mcp_support', false ) && isset( $mwai ) ) { |
| 24 |
// Register MCP tools |
| 25 |
add_filter( 'mwai_mcp_tools', array( $this, 'register_tools' ) ); |
| 26 |
|
| 27 |
// Handle MCP tool execution |
| 28 |
add_filter( 'mwai_mcp_callback', array( $this, 'handle_tool_execution' ), 10, 4 ); |
| 29 |
} |
| 30 |
} |
| 31 |
|
| 32 |
/** |
| 33 |
* Walk every post matching $query_args, one chunk at a time. |
| 34 |
* |
| 35 |
* The bulk tools used to run get_posts( [ 'posts_per_page' => -1 ] ) and hold every |
| 36 |
* WP_Post (post_content included) in memory at once. On sites with several hundred |
| 37 |
* posts that ended in a fatal and an empty HTTP 500, which killed the whole MCP |
| 38 |
* conversation for the client, not just the one tool. |
| 39 |
* |
| 40 |
* The ids are collected up front (they cost almost nothing to hold) and the posts are |
| 41 |
* then fetched one chunk at a time, so this stays at one query per chunk with the meta |
| 42 |
* cache primed by WP_Query, and the object cache is released between chunks. Fetching |
| 43 |
* by id rather than paginating also means the loop cannot drift or spin if something |
| 44 |
* filters the query. |
| 45 |
* |
| 46 |
* Any posts_per_page / offset in $query_args is ignored: this always walks them all. |
| 47 |
*/ |
| 48 |
private function each_post( $query_args ) { |
| 49 |
$post_ids = get_posts( array_merge( $query_args, [ |
| 50 |
'posts_per_page' => -1, |
| 51 |
'fields' => 'ids' |
| 52 |
] ) ); |
| 53 |
|
| 54 |
foreach ( array_chunk( $post_ids, self::BULK_CHUNK_SIZE ) as $chunk ) { |
| 55 |
$posts = get_posts( array_merge( $query_args, [ |
| 56 |
'post__in' => $chunk, |
| 57 |
'posts_per_page' => count( $chunk ), |
| 58 |
'orderby' => 'post__in' |
| 59 |
] ) ); |
| 60 |
|
| 61 |
foreach ( $posts as $post ) { |
| 62 |
yield $post; |
| 63 |
} |
| 64 |
|
| 65 |
$this->flush_runtime_cache(); |
| 66 |
} |
| 67 |
} |
| 68 |
|
| 69 |
/** |
| 70 |
* Release the in-memory object cache so a long scan stays flat. |
| 71 |
* |
| 72 |
* wp_cache_supports() (WP 6.1+) has to be consulted first: core's compat shim calls |
| 73 |
* _doing_it_wrong() for object cache drop-ins that don't advertise flush_runtime, and |
| 74 |
* with WP_DEBUG on that notice can leak into the response body and break the JSON the |
| 75 |
* MCP client parses. Such drop-ins simply keep their cache; deleting the entries |
| 76 |
* instead would evict them from the persistent cache, which is worse. |
| 77 |
*/ |
| 78 |
private function flush_runtime_cache() { |
| 79 |
if ( !function_exists( 'wp_cache_flush_runtime' ) ) { |
| 80 |
return; |
| 81 |
} |
| 82 |
if ( function_exists( 'wp_cache_supports' ) && !wp_cache_supports( 'flush_runtime' ) ) { |
| 83 |
return; |
| 84 |
} |
| 85 |
wp_cache_flush_runtime(); |
| 86 |
} |
| 87 |
|
| 88 |
/** |
| 89 |
* The description a post falls back to when it has no custom SEO excerpt, without |
| 90 |
* running the content filter stack (see evaluate_effective_seo). |
| 91 |
* |
| 92 |
* Mirrors wp_trim_excerpt(): the manual excerpt wins, otherwise the raw content is |
| 93 |
* trimmed with the same excerpt_length / excerpt_more filters, so the length we |
| 94 |
* measure matches the excerpt the site actually outputs. |
| 95 |
*/ |
| 96 |
private function build_light_excerpt( $post ) { |
| 97 |
if ( !empty( $post->post_excerpt ) ) { |
| 98 |
return $post->post_excerpt; |
| 99 |
} |
| 100 |
$length = (int) apply_filters( 'excerpt_length', (int) _x( '55', 'excerpt_length' ) ); |
| 101 |
$more = apply_filters( 'excerpt_more', ' […]' ); |
| 102 |
// wp_trim_words() strips the tags (and the block delimiter comments) for us. |
| 103 |
return wp_trim_words( strip_shortcodes( $post->post_content ), $length, $more ); |
| 104 |
} |
| 105 |
|
| 106 |
/** |
| 107 |
* Evaluate the effective SEO quality of a post. |
| 108 |
* Returns info about both custom and auto-generated SEO, plus any issues detected. |
| 109 |
* This helps distinguish between "no custom SEO" vs "actually problematic SEO". |
| 110 |
* |
| 111 |
* The description falls back to build_light_excerpt() rather than get_the_excerpt(), |
| 112 |
* which renders blocks, shortcodes and embeds for every post it touches. That is fine |
| 113 |
* for a single post but it exhausted memory across a whole site, so all the bulk |
| 114 |
* tools share this lighter evaluation and report the same numbers. |
| 115 |
*/ |
| 116 |
private function evaluate_effective_seo( $post ) { |
| 117 |
$has_custom_title = (bool) get_post_meta( $post->ID, $this->core->meta_key_seo_title, true ); |
| 118 |
$has_custom_desc = (bool) get_post_meta( $post->ID, $this->core->meta_key_seo_excerpt, true ); |
| 119 |
|
| 120 |
$effective_title = $this->core->get_seo_title( $post ); |
| 121 |
$effective_desc = $has_custom_desc ? $this->core->get_seo_excerpt( $post ) : |
| 122 |
$this->build_light_excerpt( $post ); |
| 123 |
|
| 124 |
// Use display width instead of character count (CJK chars count as 2). Measured on |
| 125 |
// the decoded text, since that is what ends up in the SERP: a custom title kept as |
| 126 |
// "Cats & Dogs" is 11 units wide, not 15. |
| 127 |
$title_width = $this->core->get_display_width( html_entity_decode( (string) $effective_title ) ); |
| 128 |
$desc_width = $this->core->get_display_width( html_entity_decode( (string) $effective_desc ) ); |
| 129 |
|
| 130 |
// Evaluate title quality (optimal: 30-75 display units) |
| 131 |
$title_issues = []; |
| 132 |
if ( $title_width < 30 ) { |
| 133 |
$title_issues[] = 'too_short'; |
| 134 |
} |
| 135 |
if ( $title_width > 75 ) { |
| 136 |
$title_issues[] = 'too_long'; |
| 137 |
} |
| 138 |
|
| 139 |
// Evaluate description quality (optimal: 80-160 display units) |
| 140 |
$desc_issues = []; |
| 141 |
if ( empty( $effective_desc ) || $desc_width < 80 ) { |
| 142 |
$desc_issues[] = 'too_short'; |
| 143 |
} |
| 144 |
if ( $desc_width > 160 ) { |
| 145 |
$desc_issues[] = 'too_long'; |
| 146 |
} |
| 147 |
|
| 148 |
return [ |
| 149 |
'has_custom_title' => $has_custom_title, |
| 150 |
'has_custom_description' => $has_custom_desc, |
| 151 |
'effective_title' => $effective_title, |
| 152 |
'effective_description' => $effective_desc, |
| 153 |
'title_display_width' => $title_width, |
| 154 |
'description_display_width' => $desc_width, |
| 155 |
'title_issues' => $title_issues, |
| 156 |
'description_issues' => $desc_issues, |
| 157 |
'needs_attention' => !empty( $title_issues ) || !empty( $desc_issues ) |
| 158 |
]; |
| 159 |
} |
| 160 |
|
| 161 |
public function register_tools( $tools ) { |
| 162 |
// IMPORTANT: When defining inputSchema with no properties, do NOT include an empty |
| 163 |
// 'properties' => [] array. This can cause MCP parsers to fail silently. |
| 164 |
// Either omit the properties key entirely or ensure at least one property exists. |
| 165 |
|
| 166 |
// SEO Title Operations |
| 167 |
$tools[] = [ |
| 168 |
'name' => 'mwseo_get_seo_title', |
| 169 |
'description' => 'Get the SEO meta title for a post. This is the title that appears in search engine results (SERPs) and browser tabs, not the WordPress post title. Returns the custom SEO title if set, otherwise returns the default generated title. Use this to see what title search engines will display.', |
| 170 |
'category' => 'SEO Engine', |
| 171 |
'accessLevel' => 'read', |
| 172 |
'inputSchema' => [ |
| 173 |
'type' => 'object', |
| 174 |
'properties' => [ |
| 175 |
'post_id' => [ |
| 176 |
'type' => 'integer', |
| 177 |
'description' => 'The WordPress post ID. You can get this from mwseo_search_posts, mwseo_get_post_by_slug, or other discovery tools.' |
| 178 |
] |
| 179 |
], |
| 180 |
'required' => ['post_id'] |
| 181 |
] |
| 182 |
]; |
| 183 |
|
| 184 |
$tools[] = [ |
| 185 |
'name' => 'mwseo_set_seo_title', |
| 186 |
'description' => 'Set a custom SEO meta title for a post. This overrides the WordPress post title for search engines. Optimal length is 50-60 characters to avoid truncation in search results. The title should be compelling and include target keywords near the beginning.', |
| 187 |
'category' => 'SEO Engine', |
| 188 |
'accessLevel' => 'write', |
| 189 |
'inputSchema' => [ |
| 190 |
'type' => 'object', |
| 191 |
'properties' => [ |
| 192 |
'post_id' => [ |
| 193 |
'type' => 'integer', |
| 194 |
'description' => 'The WordPress post ID to update' |
| 195 |
], |
| 196 |
'title' => [ |
| 197 |
'type' => 'string', |
| 198 |
'description' => 'The SEO title to set. Should be 50-60 characters for optimal display in search results. Include important keywords at the start.' |
| 199 |
] |
| 200 |
], |
| 201 |
'required' => ['post_id', 'title'] |
| 202 |
] |
| 203 |
]; |
| 204 |
|
| 205 |
// SEO Excerpt Operations |
| 206 |
$tools[] = [ |
| 207 |
'name' => 'mwseo_get_seo_excerpt', |
| 208 |
'description' => 'Get the SEO meta description for a post. This is the description snippet that appears under the title in search engine results. Returns the custom meta description if set, otherwise returns the default excerpt. This is crucial for click-through rates from search results.', |
| 209 |
'category' => 'SEO Engine', |
| 210 |
'accessLevel' => 'read', |
| 211 |
'inputSchema' => [ |
| 212 |
'type' => 'object', |
| 213 |
'properties' => [ |
| 214 |
'post_id' => [ |
| 215 |
'type' => 'integer', |
| 216 |
'description' => 'The WordPress post ID' |
| 217 |
] |
| 218 |
], |
| 219 |
'required' => ['post_id'] |
| 220 |
] |
| 221 |
]; |
| 222 |
|
| 223 |
$tools[] = [ |
| 224 |
'name' => 'mwseo_set_seo_excerpt', |
| 225 |
'description' => 'Set the SEO meta description for a post. This text appears in search results under the title and should be 80-160 characters. Write it like an elevator pitch - compelling, clear, and including target keywords naturally. This directly impacts click-through rate from search results.', |
| 226 |
'category' => 'SEO Engine', |
| 227 |
'accessLevel' => 'write', |
| 228 |
'inputSchema' => [ |
| 229 |
'type' => 'object', |
| 230 |
'properties' => [ |
| 231 |
'post_id' => [ |
| 232 |
'type' => 'integer', |
| 233 |
'description' => 'The WordPress post ID to update' |
| 234 |
], |
| 235 |
'excerpt' => [ |
| 236 |
'type' => 'string', |
| 237 |
'description' => 'The meta description text. Should be 80-160 characters, compelling, and include target keywords naturally.' |
| 238 |
] |
| 239 |
], |
| 240 |
'required' => ['post_id', 'excerpt'] |
| 241 |
] |
| 242 |
]; |
| 243 |
|
| 244 |
// SEO Score Operations |
| 245 |
$tools[] = [ |
| 246 |
'name' => 'mwseo_get_seo_score', |
| 247 |
'description' => 'Get the complete SEO analysis for a post including score (0-100), status, detailed test results, and all issues found. Returns the full analysis object with scores for individual tests like title_exists, excerpt_length, readability_score, etc. Each test returns a score or "NA" if not applicable. Use this to understand exactly what SEO issues a post has.', |
| 248 |
'category' => 'SEO Engine', |
| 249 |
'accessLevel' => 'read', |
| 250 |
'inputSchema' => [ |
| 251 |
'type' => 'object', |
| 252 |
'properties' => [ |
| 253 |
'post_id' => [ |
| 254 |
'type' => 'integer', |
| 255 |
'description' => 'The WordPress post ID to analyze' |
| 256 |
] |
| 257 |
], |
| 258 |
'required' => ['post_id'] |
| 259 |
] |
| 260 |
]; |
| 261 |
|
| 262 |
$tools[] = [ |
| 263 |
'name' => 'mwseo_do_seo_scan', |
| 264 |
'description' => 'Run a fresh comprehensive SEO analysis on a post. This re-calculates the SEO score by analyzing content quality, meta tags, readability, image alt text, internal/external links, and more. Use this after making changes to a post to see updated scores. Returns the complete analysis with individual test scores and overall rating.', |
| 265 |
'category' => 'SEO Engine', |
| 266 |
'accessLevel' => 'write', |
| 267 |
'inputSchema' => [ |
| 268 |
'type' => 'object', |
| 269 |
'properties' => [ |
| 270 |
'post_id' => [ |
| 271 |
'type' => 'integer', |
| 272 |
'description' => 'The WordPress post ID to scan. This will perform a fresh analysis and update stored scores.' |
| 273 |
] |
| 274 |
], |
| 275 |
'required' => ['post_id'] |
| 276 |
] |
| 277 |
]; |
| 278 |
|
| 279 |
$tools[] = [ |
| 280 |
'name' => 'mwseo_get_scored_posts', |
| 281 |
'description' => 'Get a list of all posts that have been analyzed with their SEO scores. Useful for identifying posts that need SEO improvements. Supports filtering by post type and status to narrow down results.', |
| 282 |
'category' => 'SEO Engine', |
| 283 |
'accessLevel' => 'read', |
| 284 |
'inputSchema' => [ |
| 285 |
'type' => 'object', |
| 286 |
'properties' => [ |
| 287 |
'post_type' => [ |
| 288 |
'type' => 'string', |
| 289 |
'description' => 'Filter by post type (e.g., "post", "page", "product"). Leave empty for all types.' |
| 290 |
], |
| 291 |
'status' => [ |
| 292 |
'type' => 'string', |
| 293 |
'description' => 'Filter by status: "ok" (good SEO), "error" (needs improvement), "skip" (skipped posts), or leave empty for all.' |
| 294 |
], |
| 295 |
'limit' => [ |
| 296 |
'type' => 'integer', |
| 297 |
'description' => 'Maximum number of results to return', |
| 298 |
'default' => 100 |
| 299 |
] |
| 300 |
] |
| 301 |
] |
| 302 |
]; |
| 303 |
|
| 304 |
// Insights |
| 305 |
$tools[] = [ |
| 306 |
'name' => 'mwseo_get_insights', |
| 307 |
'description' => 'Get Google PageSpeed Insights data for a specific post URL. Returns performance metrics, Core Web Vitals (LCP, FID, CLS), accessibility score, best practices compliance, and SEO technical audit. This analyzes actual page load performance from Google\'s perspective. Note: This makes a live API call to Google and may take a few seconds.', |
| 308 |
'category' => 'SEO Engine', |
| 309 |
'accessLevel' => 'read', |
| 310 |
'inputSchema' => [ |
| 311 |
'type' => 'object', |
| 312 |
'properties' => [ |
| 313 |
'post_id' => [ |
| 314 |
'type' => 'integer', |
| 315 |
'description' => 'The WordPress post ID to analyze. The post must be published and accessible publicly.' |
| 316 |
] |
| 317 |
], |
| 318 |
'required' => ['post_id'] |
| 319 |
] |
| 320 |
]; |
| 321 |
|
| 322 |
// Robots.txt Operations |
| 323 |
$tools[] = [ |
| 324 |
'name' => 'mwseo_get_robots_txt', |
| 325 |
'description' => 'Get the current robots.txt file content from the website root. This file tells search engine crawlers which pages they can and cannot access. Returns the actual file content if it exists, otherwise returns the WordPress default robots.txt rules.', |
| 326 |
'category' => 'SEO Engine', |
| 327 |
'accessLevel' => 'read', |
| 328 |
'inputSchema' => [ |
| 329 |
'type' => 'object' |
| 330 |
] |
| 331 |
]; |
| 332 |
|
| 333 |
$tools[] = [ |
| 334 |
'name' => 'mwseo_set_robots_txt', |
| 335 |
'description' => 'Update the robots.txt file in the website root. Use this to control which search engine crawlers can access which parts of your site. IMPORTANT: Be very careful - incorrect rules can accidentally block search engines from indexing your entire site. Always include "User-agent: *" and "Sitemap:" directives.', |
| 336 |
'category' => 'SEO Engine', |
| 337 |
'accessLevel' => 'write', |
| 338 |
'inputSchema' => [ |
| 339 |
'type' => 'object', |
| 340 |
'properties' => [ |
| 341 |
'content' => [ |
| 342 |
'type' => 'string', |
| 343 |
'description' => 'The complete robots.txt content. Must follow robots.txt syntax with User-agent and Disallow/Allow directives. Include sitemap URL.' |
| 344 |
] |
| 345 |
], |
| 346 |
'required' => ['content'] |
| 347 |
] |
| 348 |
]; |
| 349 |
|
| 350 |
// Analytics Operations (Source-Agnostic) |
| 351 |
$tools[] = [ |
| 352 |
'name' => 'mwseo_get_analytics_data', |
| 353 |
'description' => 'Get analytics data from the currently configured source (Google Analytics, Plausible Analytics, Matomo Analytics, or Private Analytics). Specify metric="summary" for traffic overview (visitors, pageviews, sessions, bounce rate) or metric="top_posts" for most visited content. Supports date range filtering and country filtering. Defaults to current month if dates omitted. Examples: (1) Get current month summary: metric="summary". (2) Get January top posts: metric="top_posts", start_date="2024-01-01", end_date="2024-01-31". (3) Get US traffic only: metric="top_posts", country="US". Respects the Display Source setting in the dashboard.', |
| 354 |
'category' => 'SEO Engine', |
| 355 |
'accessLevel' => 'read', |
| 356 |
'inputSchema' => [ |
| 357 |
'type' => 'object', |
| 358 |
'properties' => [ |
| 359 |
'metric' => [ |
| 360 |
'type' => 'string', |
| 361 |
'description' => 'Type of data to fetch: "summary" (traffic overview) or "top_posts" (most visited content)', |
| 362 |
'enum' => ['summary', 'top_posts'] |
| 363 |
], |
| 364 |
'start_date' => [ |
| 365 |
'type' => 'string', |
| 366 |
'description' => 'Optional: Start date in YYYY-MM-DD format. Omit for current month.' |
| 367 |
], |
| 368 |
'end_date' => [ |
| 369 |
'type' => 'string', |
| 370 |
'description' => 'Optional: End date in YYYY-MM-DD format. Omit for current month.' |
| 371 |
], |
| 372 |
'country' => [ |
| 373 |
'type' => 'string', |
| 374 |
'description' => 'Optional: Filter by ISO country code ("US", "GB", "FR", etc.) or "all" for all countries. Only applies to top_posts metric and only works if the analytics source provides country data (Google Analytics, Plausible Analytics).' |
| 375 |
], |
| 376 |
'limit' => [ |
| 377 |
'type' => 'integer', |
| 378 |
'description' => 'Optional: Maximum posts to return when metric="top_posts". Default 20.', |
| 379 |
'default' => 20 |
| 380 |
] |
| 381 |
], |
| 382 |
'required' => ['metric'] |
| 383 |
] |
| 384 |
]; |
| 385 |
|
| 386 |
$tools[] = [ |
| 387 |
'name' => 'mwseo_get_post_analytics', |
| 388 |
'description' => 'Get analytics data for a specific post or page. Returns visits, unique visitors, pageviews, and other metrics for the given post. Useful for answering questions like "how many visits does this page get?" or "what is the traffic for this article?". Supports date range filtering. Defaults to current month if dates omitted. Respects the Display Source setting in the dashboard.', |
| 389 |
'category' => 'SEO Engine', |
| 390 |
'accessLevel' => 'read', |
| 391 |
'inputSchema' => [ |
| 392 |
'type' => 'object', |
| 393 |
'properties' => [ |
| 394 |
'post_id' => [ |
| 395 |
'type' => 'integer', |
| 396 |
'description' => 'The WordPress post ID to get analytics for.' |
| 397 |
], |
| 398 |
'start_date' => [ |
| 399 |
'type' => 'string', |
| 400 |
'description' => 'Optional: Start date in YYYY-MM-DD format. Omit for current month.' |
| 401 |
], |
| 402 |
'end_date' => [ |
| 403 |
'type' => 'string', |
| 404 |
'description' => 'Optional: End date in YYYY-MM-DD format. Omit for current month.' |
| 405 |
] |
| 406 |
], |
| 407 |
'required' => ['post_id'] |
| 408 |
] |
| 409 |
]; |
| 410 |
|
| 411 |
$tools[] = [ |
| 412 |
'name' => 'mwseo_get_analytics_top_countries', |
| 413 |
'description' => 'Get a ranked list of countries where your website visitors come from, sorted by traffic volume. Returns country codes and visitor counts aggregated from top posts data. Helps identify your main audience locations for targeted content strategy and localization decisions. Note: Country data requires Google Analytics or Plausible Analytics; will return an error if using Private Analytics.', |
| 414 |
'category' => 'SEO Engine', |
| 415 |
'accessLevel' => 'read', |
| 416 |
'inputSchema' => [ |
| 417 |
'type' => 'object' |
| 418 |
] |
| 419 |
]; |
| 420 |
|
| 421 |
// Utility Tools |
| 422 |
$tools[] = [ |
| 423 |
'name' => 'mwseo_get_post_by_slug', |
| 424 |
'description' => 'Look up a post by its URL slug to get the WordPress post ID. The slug is the URL-friendly part of the post URL (e.g., "my-awesome-post" from example.com/my-awesome-post). Returns post ID, title, and type. Use this when you know the URL but need the post ID for other operations.', |
| 425 |
'category' => 'SEO Engine', |
| 426 |
'accessLevel' => 'read', |
| 427 |
'inputSchema' => [ |
| 428 |
'type' => 'object', |
| 429 |
'properties' => [ |
| 430 |
'slug' => [ |
| 431 |
'type' => 'string', |
| 432 |
'description' => 'The URL slug of the post (the part after the domain in the URL, without slashes)' |
| 433 |
], |
| 434 |
'post_type' => [ |
| 435 |
'type' => 'string', |
| 436 |
'description' => 'The WordPress post type to search in. Use "post" for blog posts, "page" for pages, "product" for WooCommerce products.', |
| 437 |
'default' => 'post' |
| 438 |
] |
| 439 |
], |
| 440 |
'required' => ['slug'] |
| 441 |
] |
| 442 |
]; |
| 443 |
|
| 444 |
$tools[] = [ |
| 445 |
'name' => 'mwseo_bulk_seo_scan', |
| 446 |
'description' => 'Refresh SEO scores for multiple posts at once using QUICK scans (fast baseline checks; existing AI results are preserved). This is the right tool after content fixes, when issue counts have gone stale. Each call processes at most 20 posts; extra IDs come back in "skipped" so you can chunk follow-up calls. For a full AI re-analysis of a single post, use mwseo_do_seo_scan instead.', |
| 447 |
'category' => 'SEO Engine', |
| 448 |
'accessLevel' => 'write', |
| 449 |
'inputSchema' => [ |
| 450 |
'type' => 'object', |
| 451 |
'properties' => [ |
| 452 |
'post_ids' => [ |
| 453 |
'type' => 'array', |
| 454 |
'description' => 'Array of WordPress post IDs to scan, max 20 per call (extras are returned in "skipped" for the next call). Example: [123, 456, 789]', |
| 455 |
'items' => [ |
| 456 |
'type' => 'integer' |
| 457 |
] |
| 458 |
] |
| 459 |
], |
| 460 |
'required' => ['post_ids'] |
| 461 |
] |
| 462 |
]; |
| 463 |
|
| 464 |
// Advanced SEO Tools |
| 465 |
$tools[] = [ |
| 466 |
'name' => 'mwseo_get_posts_by_score_range', |
| 467 |
'description' => 'Find all posts with SEO scores within a specific range. Useful for targeted optimization - find posts scoring 40-69 that need improvement, or 70+ that are doing well. Scores: 0-39=Poor, 40-69=Needs Work, 70+=Good. Returns post IDs, titles, and current scores.', |
| 468 |
'category' => 'SEO Engine', |
| 469 |
'accessLevel' => 'read', |
| 470 |
'inputSchema' => [ |
| 471 |
'type' => 'object', |
| 472 |
'properties' => [ |
| 473 |
'min_score' => [ |
| 474 |
'type' => 'integer', |
| 475 |
'description' => 'Minimum SEO score (0-100). For example, use 0 to find worst posts, or 40 to find mediocre posts.', |
| 476 |
'minimum' => 0, |
| 477 |
'maximum' => 100 |
| 478 |
], |
| 479 |
'max_score' => [ |
| 480 |
'type' => 'integer', |
| 481 |
'description' => 'Maximum SEO score (0-100). For example, use 39 for poor posts, or 100 for all posts above minimum.', |
| 482 |
'minimum' => 0, |
| 483 |
'maximum' => 100 |
| 484 |
] |
| 485 |
], |
| 486 |
'required' => ['min_score', 'max_score'] |
| 487 |
] |
| 488 |
]; |
| 489 |
|
| 490 |
$tools[] = [ |
| 491 |
'name' => 'mwseo_get_posts_missing_seo', |
| 492 |
'description' => 'Find posts without CUSTOM SEO titles or descriptions. Note: Posts without custom SEO use auto-generated values from the WordPress title/excerpt, which are often perfectly adequate. For posts where the effective SEO actually has problems (too short, too long), use mwseo_get_posts_needing_seo instead.', |
| 493 |
'category' => 'SEO Engine', |
| 494 |
'accessLevel' => 'read', |
| 495 |
'inputSchema' => [ |
| 496 |
'type' => 'object', |
| 497 |
'properties' => [ |
| 498 |
'post_type' => [ |
| 499 |
'type' => 'string', |
| 500 |
'description' => 'Filter by post type: "post", "page", "product", etc. Leave empty to search all post types.', |
| 501 |
'default' => '' |
| 502 |
], |
| 503 |
'limit' => [ |
| 504 |
'type' => 'integer', |
| 505 |
'description' => 'Maximum number of results to return', |
| 506 |
'default' => 50 |
| 507 |
] |
| 508 |
] |
| 509 |
] |
| 510 |
]; |
| 511 |
|
| 512 |
$tools[] = [ |
| 513 |
'name' => 'mwseo_get_posts_needing_seo', |
| 514 |
'description' => 'Find posts where the EFFECTIVE SEO (custom or auto-generated) has actual problems. Uses display width (CJK characters count as 2) to approximate Google SERP pixel limits. Flags titles outside 30-75 width and descriptions outside 80-160 width. More actionable than mwseo_get_posts_missing_seo because it finds posts that genuinely need attention, not just posts without custom SEO.', |
| 515 |
'category' => 'SEO Engine', |
| 516 |
'accessLevel' => 'read', |
| 517 |
'inputSchema' => [ |
| 518 |
'type' => 'object', |
| 519 |
'properties' => [ |
| 520 |
'post_type' => [ |
| 521 |
'type' => 'string', |
| 522 |
'description' => 'Filter by post type: "post", "page", "product", etc. Leave empty to search all post types.', |
| 523 |
'default' => '' |
| 524 |
], |
| 525 |
'issue_type' => [ |
| 526 |
'type' => 'string', |
| 527 |
'description' => 'Filter by issue type: "title" for title issues only, "description" for description issues only, "any" for either (default).', |
| 528 |
'default' => 'any' |
| 529 |
], |
| 530 |
'limit' => [ |
| 531 |
'type' => 'integer', |
| 532 |
'description' => 'Maximum number of results to return', |
| 533 |
'default' => 50 |
| 534 |
] |
| 535 |
] |
| 536 |
] |
| 537 |
]; |
| 538 |
|
| 539 |
$tools[] = [ |
| 540 |
'name' => 'mwseo_search_posts', |
| 541 |
'description' => 'Search posts by title or content keywords. Use this to find specific posts when you don\'t know the post ID. Returns matching posts with their IDs, titles, permalinks, and SEO scores if available. Useful for finding posts about specific topics for optimization.', |
| 542 |
'category' => 'SEO Engine', |
| 543 |
'accessLevel' => 'read', |
| 544 |
'inputSchema' => [ |
| 545 |
'type' => 'object', |
| 546 |
'properties' => [ |
| 547 |
'search_term' => [ |
| 548 |
'type' => 'string', |
| 549 |
'description' => 'The keyword or phrase to search for in post titles and content' |
| 550 |
], |
| 551 |
'post_type' => [ |
| 552 |
'type' => 'string', |
| 553 |
'description' => 'Filter by post type like "post", "page", etc. Leave empty to search all types.', |
| 554 |
'default' => '' |
| 555 |
], |
| 556 |
'limit' => [ |
| 557 |
'type' => 'integer', |
| 558 |
'description' => 'Maximum number of results to return', |
| 559 |
'default' => 20 |
| 560 |
] |
| 561 |
], |
| 562 |
'required' => ['search_term'] |
| 563 |
] |
| 564 |
]; |
| 565 |
|
| 566 |
$tools[] = [ |
| 567 |
'name' => 'mwseo_get_recent_posts', |
| 568 |
'description' => 'Get recently published posts from the last N days. Perfect for auditing new content to ensure it starts with good SEO. Returns posts with their SEO scores and whether they have SEO titles/descriptions set. Use this to catch SEO issues early on new content.', |
| 569 |
'category' => 'SEO Engine', |
| 570 |
'accessLevel' => 'read', |
| 571 |
'inputSchema' => [ |
| 572 |
'type' => 'object', |
| 573 |
'properties' => [ |
| 574 |
'days' => [ |
| 575 |
'type' => 'integer', |
| 576 |
'description' => 'Number of days to look back. Default is 7 (last week).', |
| 577 |
'default' => 7 |
| 578 |
], |
| 579 |
'post_type' => [ |
| 580 |
'type' => 'string', |
| 581 |
'description' => 'Filter by post type like "post" or "page"', |
| 582 |
'default' => 'post' |
| 583 |
] |
| 584 |
] |
| 585 |
] |
| 586 |
]; |
| 587 |
|
| 588 |
$tools[] = [ |
| 589 |
'name' => 'mwseo_generate_sitemap_preview', |
| 590 |
'description' => 'Preview what URLs would be included in the XML sitemap without generating the actual file. Shows post URLs, last modified dates, and post types. Useful for understanding what content search engines will discover through the sitemap.', |
| 591 |
'category' => 'SEO Engine', |
| 592 |
'accessLevel' => 'read', |
| 593 |
'inputSchema' => [ |
| 594 |
'type' => 'object', |
| 595 |
'properties' => [ |
| 596 |
'post_type' => [ |
| 597 |
'type' => 'string', |
| 598 |
'description' => 'Filter by specific post type like "post" or "page", or leave empty to preview all types', |
| 599 |
'default' => '' |
| 600 |
], |
| 601 |
'limit' => [ |
| 602 |
'type' => 'integer', |
| 603 |
'description' => 'Maximum number of URLs to include in preview', |
| 604 |
'default' => 100 |
| 605 |
] |
| 606 |
] |
| 607 |
] |
| 608 |
]; |
| 609 |
|
| 610 |
$tools[] = [ |
| 611 |
'name' => 'mwseo_check_duplicate_titles', |
| 612 |
'description' => 'Find posts with identical SEO titles. Duplicate titles confuse search engines about which page to rank for a query, hurting SEO for both pages. Returns groups of posts sharing the same title. Fix these by making each title unique and descriptive.', |
| 613 |
'category' => 'SEO Engine', |
| 614 |
'accessLevel' => 'read', |
| 615 |
'inputSchema' => [ |
| 616 |
'type' => 'object' |
| 617 |
] |
| 618 |
]; |
| 619 |
|
| 620 |
$tools[] = [ |
| 621 |
'name' => 'mwseo_get_seo_statistics', |
| 622 |
'description' => 'Get comprehensive SEO statistics for the entire website. Returns total posts, average SEO score, score distribution (A/B/C/D/F grades), custom SEO rates, AND counts of posts with actual SEO issues (title/description too short or too long). The "posts_needing_attention" count shows posts that genuinely need work, while "custom_title_rate" just shows customization rate (low rate is fine if auto-generated titles are good).', |
| 623 |
'category' => 'SEO Engine', |
| 624 |
'accessLevel' => 'read', |
| 625 |
'inputSchema' => [ |
| 626 |
'type' => 'object' |
| 627 |
] |
| 628 |
]; |
| 629 |
|
| 630 |
// AI Keywords |
| 631 |
$tools[] = [ |
| 632 |
'name' => 'mwseo_get_ai_keywords', |
| 633 |
'description' => 'Get the AI-extracted keywords for a post. These keywords help SEO Engine optimize the content analysis and scoring. They are NOT WordPress tags or categories, but semantic keywords that guide the SEO optimization process.', |
| 634 |
'category' => 'SEO Engine', |
| 635 |
'accessLevel' => 'read', |
| 636 |
'inputSchema' => [ |
| 637 |
'type' => 'object', |
| 638 |
'properties' => [ |
| 639 |
'post_id' => [ |
| 640 |
'type' => 'integer', |
| 641 |
'description' => 'The WordPress post ID' |
| 642 |
] |
| 643 |
], |
| 644 |
'required' => ['post_id'] |
| 645 |
] |
| 646 |
]; |
| 647 |
|
| 648 |
$tools[] = [ |
| 649 |
'name' => 'mwseo_set_ai_keywords', |
| 650 |
'description' => 'Set AI keywords for a post to guide SEO Engine optimization. These keywords help the plugin understand what topics and concepts are important in the content, enabling better SEO analysis and recommendations. They are internal to SEO Engine and not WordPress tags.', |
| 651 |
'category' => 'SEO Engine', |
| 652 |
'accessLevel' => 'write', |
| 653 |
'inputSchema' => [ |
| 654 |
'type' => 'object', |
| 655 |
'properties' => [ |
| 656 |
'post_id' => [ |
| 657 |
'type' => 'integer', |
| 658 |
'description' => 'The WordPress post ID' |
| 659 |
], |
| 660 |
'keywords' => [ |
| 661 |
'type' => 'array', |
| 662 |
'description' => 'Array of keyword strings (e.g., ["machine learning", "artificial intelligence", "neural networks"]). Usually 3-5 keywords work best.', |
| 663 |
'items' => [ |
| 664 |
'type' => 'string' |
| 665 |
] |
| 666 |
] |
| 667 |
], |
| 668 |
'required' => ['post_id', 'keywords'] |
| 669 |
] |
| 670 |
]; |
| 671 |
|
| 672 |
// Bot Analytics - Advanced Tools |
| 673 |
$tools[] = [ |
| 674 |
'name' => 'mwseo_query_bot_traffic', |
| 675 |
'description' => 'Flexible query tool for AI bot traffic with timeline analysis and rollups. Returns both aggregate statistics and time-series data to answer questions like "Show me ClaudeBot activity on my pricing page this month, grouped by day" or "What\'s the overall bot traffic trend?". This single tool covers most bot traffic analysis needs including trends, top pages, and specific post tracking.', |
| 676 |
'category' => 'SEO Engine', |
| 677 |
'accessLevel' => 'read', |
| 678 |
'inputSchema' => [ |
| 679 |
'type' => 'object', |
| 680 |
'properties' => [ |
| 681 |
'start_date' => [ |
| 682 |
'type' => 'string', |
| 683 |
'description' => 'Start date in YYYY-MM-DD format. Defaults to 30 days ago if omitted. Example: "2025-10-01"' |
| 684 |
], |
| 685 |
'end_date' => [ |
| 686 |
'type' => 'string', |
| 687 |
'description' => 'End date in YYYY-MM-DD format. Defaults to today if omitted. Example: "2025-10-23"' |
| 688 |
], |
| 689 |
'post_id' => [ |
| 690 |
'type' => 'integer', |
| 691 |
'description' => 'Optional: Filter by specific post ID to see bot traffic for a single post. Omit to see site-wide traffic.' |
| 692 |
], |
| 693 |
'bot_name' => [ |
| 694 |
'type' => 'string', |
| 695 |
'description' => 'Optional: Filter by specific bot name (e.g., "GPTBot", "ClaudeBot", "Google-Extended", "PerplexityBot"). Omit to see all bots. Case-sensitive exact match.' |
| 696 |
], |
| 697 |
'bot_type' => [ |
| 698 |
'type' => 'string', |
| 699 |
'description' => 'Optional: Filter by bot category. "ai" = AI assistants, trainers and answer engines (GPTBot, ClaudeBot, PerplexityBot, Google-Extended...); "search" = classic search-index crawlers (Googlebot family, bingbot); "all" = no filter. One call replaces summing per-bot queries by hand.', |
| 700 |
'enum' => ['ai', 'search', 'all'] |
| 701 |
], |
| 702 |
'group_by' => [ |
| 703 |
'type' => 'string', |
| 704 |
'description' => 'Optional: Time grouping for trend analysis. Options: "hour" (hourly breakdown), "day" (daily breakdown - most common), "week" (weekly aggregates), "month" (monthly aggregates). Omit for aggregates only without timeline.', |
| 705 |
'enum' => ['hour', 'day', 'week', 'month'] |
| 706 |
], |
| 707 |
'metric' => [ |
| 708 |
'type' => 'string', |
| 709 |
'description' => 'Metric to track in time-series. Options: "visits" (count of bot visits - default), "unique_posts" (number of different posts visited per period). Only relevant when group_by is specified.', |
| 710 |
'default' => 'visits', |
| 711 |
'enum' => ['visits', 'unique_posts'] |
| 712 |
] |
| 713 |
] |
| 714 |
] |
| 715 |
]; |
| 716 |
|
| 717 |
$tools[] = [ |
| 718 |
'name' => 'mwseo_rank_posts_for_bots', |
| 719 |
'description' => 'Rank posts by bot visit frequency to find most or least visited content. Perfect for discovering which content attracts AI crawlers (most visited) or which published posts are being ignored (least visited). Supports filtering by bot type, post type, and minimum visit thresholds.', |
| 720 |
'category' => 'SEO Engine', |
| 721 |
'accessLevel' => 'read', |
| 722 |
'inputSchema' => [ |
| 723 |
'type' => 'object', |
| 724 |
'properties' => [ |
| 725 |
'order' => [ |
| 726 |
'type' => 'string', |
| 727 |
'description' => 'Ranking order: "most" returns highest-traffic posts first (popular content), "least" returns lowest-traffic posts first (neglected content). Default is "most".', |
| 728 |
'default' => 'most', |
| 729 |
'enum' => ['most', 'least'] |
| 730 |
], |
| 731 |
'limit' => [ |
| 732 |
'type' => 'integer', |
| 733 |
'description' => 'Maximum number of posts to return. Default is 20, useful for quick overviews. Set higher (e.g., 50-100) for comprehensive audits.', |
| 734 |
'default' => 20 |
| 735 |
], |
| 736 |
'min_visits' => [ |
| 737 |
'type' => 'integer', |
| 738 |
'description' => 'Minimum visit threshold. Only return posts with at least this many bot visits. Default 0 shows all posts. Use 1+ when order="least" to exclude completely unvisited posts.', |
| 739 |
'default' => 0 |
| 740 |
], |
| 741 |
'bot_name' => [ |
| 742 |
'type' => 'string', |
| 743 |
'description' => 'Optional: Filter by specific bot (e.g., "ClaudeBot") to see which posts that bot prefers. Omit to consider all bots.' |
| 744 |
], |
| 745 |
'post_type' => [ |
| 746 |
'type' => 'string', |
| 747 |
'description' => 'Optional: Filter by WordPress post type (e.g., "post", "page", "product"). Useful for analyzing specific content types. Omit to include all post types.' |
| 748 |
], |
| 749 |
'days' => [ |
| 750 |
'type' => 'integer', |
| 751 |
'description' => 'Number of days to look back from today. Default 30 (last month). Use 7 for weekly trends, 90 for quarterly analysis, etc.', |
| 752 |
'default' => 30 |
| 753 |
] |
| 754 |
] |
| 755 |
] |
| 756 |
]; |
| 757 |
|
| 758 |
$tools[] = [ |
| 759 |
'name' => 'mwseo_bot_profile', |
| 760 |
'description' => 'Comprehensive deep-dive analysis for a specific AI bot. Returns headline statistics, top visited posts, daily visit cadence, and anomaly detection (spikes vs prior period). Use this to understand a bot\'s behavior patterns, crawl frequency, content preferences, and detect unusual activity.', |
| 761 |
'category' => 'SEO Engine', |
| 762 |
'accessLevel' => 'read', |
| 763 |
'inputSchema' => [ |
| 764 |
'type' => 'object', |
| 765 |
'properties' => [ |
| 766 |
'bot_name' => [ |
| 767 |
'type' => 'string', |
| 768 |
'description' => 'Name of the bot to analyze (e.g., "GPTBot", "ClaudeBot", "Google-Extended"). Must match exactly. This is the primary identifier for the bot whose profile you want to see.' |
| 769 |
], |
| 770 |
'start_date' => [ |
| 771 |
'type' => 'string', |
| 772 |
'description' => 'Analysis period start date in YYYY-MM-DD format. Defaults to 30 days ago. The tool automatically compares against an equal prior period for anomaly detection.' |
| 773 |
], |
| 774 |
'end_date' => [ |
| 775 |
'type' => 'string', |
| 776 |
'description' => 'Analysis period end date in YYYY-MM-DD format. Defaults to today. Combined with start_date to define the analysis window.' |
| 777 |
] |
| 778 |
], |
| 779 |
'required' => ['bot_name'] |
| 780 |
] |
| 781 |
]; |
| 782 |
|
| 783 |
$tools[] = [ |
| 784 |
'name' => 'mwseo_compare_bot_periods', |
| 785 |
'description' => 'Compare bot traffic between two time periods to identify trends, growth, or decline. Returns percent changes, trend indicators (increasing/decreasing/stable), and highlights posts with the biggest traffic shifts. Useful for measuring impact of content changes, SEO improvements, or seasonal patterns in bot activity.', |
| 786 |
'category' => 'SEO Engine', |
| 787 |
'accessLevel' => 'read', |
| 788 |
'inputSchema' => [ |
| 789 |
'type' => 'object', |
| 790 |
'properties' => [ |
| 791 |
'period1_start' => [ |
| 792 |
'type' => 'string', |
| 793 |
'description' => 'Period 1 start date in YYYY-MM-DD format. Default is 60 days ago. This is your baseline/comparison period (e.g., "last month").' |
| 794 |
], |
| 795 |
'period1_end' => [ |
| 796 |
'type' => 'string', |
| 797 |
'description' => 'Period 1 end date in YYYY-MM-DD format. Default is 31 days ago. Should be before period2 for meaningful comparison.' |
| 798 |
], |
| 799 |
'period2_start' => [ |
| 800 |
'type' => 'string', |
| 801 |
'description' => 'Period 2 start date in YYYY-MM-DD format. Default is 30 days ago. This is your current/recent period (e.g., "this month").' |
| 802 |
], |
| 803 |
'period2_end' => [ |
| 804 |
'type' => 'string', |
| 805 |
'description' => 'Period 2 end date in YYYY-MM-DD format. Default is today. Marks the end of the period you\'re analyzing.' |
| 806 |
], |
| 807 |
'bot_name' => [ |
| 808 |
'type' => 'string', |
| 809 |
'description' => 'Optional: Filter comparison to a specific bot (e.g., "ClaudeBot"). Omit to compare all bot traffic across periods.' |
| 810 |
], |
| 811 |
'post_id' => [ |
| 812 |
'type' => 'integer', |
| 813 |
'description' => 'Optional: Filter comparison to a specific post ID. Useful for tracking "did bot traffic to this post increase after I updated it?". Omit for site-wide comparison.' |
| 814 |
] |
| 815 |
] |
| 816 |
] |
| 817 |
]; |
| 818 |
|
| 819 |
$tools[] = [ |
| 820 |
'name' => 'mwseo_bot_mix', |
| 821 |
'description' => 'Analyze the distribution of bot traffic across different AI crawlers with percentage breakdowns. Also detects new bots that appeared during the period (weren\'t present in prior 30 days). Useful for understanding your bot audience composition, identifying dominant crawlers, and spotting emerging AI platforms indexing your content.', |
| 822 |
'category' => 'SEO Engine', |
| 823 |
'accessLevel' => 'read', |
| 824 |
'inputSchema' => [ |
| 825 |
'type' => 'object', |
| 826 |
'properties' => [ |
| 827 |
'start_date' => [ |
| 828 |
'type' => 'string', |
| 829 |
'description' => 'Analysis period start date in YYYY-MM-DD format. Defaults to 30 days ago. Defines the window for calculating distribution percentages.' |
| 830 |
], |
| 831 |
'end_date' => [ |
| 832 |
'type' => 'string', |
| 833 |
'description' => 'Analysis period end date in YYYY-MM-DD format. Defaults to today.' |
| 834 |
], |
| 835 |
'post_type' => [ |
| 836 |
'type' => 'string', |
| 837 |
'description' => 'Optional: Segment distribution by post type (e.g., "post", "page", "product"). Useful for questions like "Which bots prefer my product pages vs blog posts?". Omit for site-wide mix.' |
| 838 |
] |
| 839 |
] |
| 840 |
] |
| 841 |
]; |
| 842 |
|
| 843 |
// Magic Fix / Issues |
| 844 |
$tools[] = [ |
| 845 |
'name' => 'mwseo_get_issues', |
| 846 |
'description' => 'Get SEO issues. With post_id: detailed per-post breakdown (which tests failed, scores, severity). Without post_id: site-wide aggregate showing the most common failing tests across all scanned posts (which problems to fix first).', |
| 847 |
'category' => 'SEO Engine', |
| 848 |
'accessLevel' => 'read', |
| 849 |
'inputSchema' => [ |
| 850 |
'type' => 'object', |
| 851 |
'properties' => [ |
| 852 |
'post_id' => [ |
| 853 |
'type' => 'integer', |
| 854 |
'description' => 'Optional. The WordPress post ID for a per-post breakdown. Omit for a site-wide aggregate.' |
| 855 |
], |
| 856 |
'sample_size' => [ |
| 857 |
'type' => 'integer', |
| 858 |
'description' => 'Site-wide mode only. Maximum number of scanned posts to aggregate over. Default 5000.', |
| 859 |
'default' => 5000 |
| 860 |
] |
| 861 |
] |
| 862 |
] |
| 863 |
]; |
| 864 |
|
| 865 |
$tools[] = [ |
| 866 |
'name' => 'mwseo_suggest_seo_title', |
| 867 |
'description' => 'Generate AI-written SEO title candidates for a post, typically used after mwseo_gsc_quick_wins surfaces a CTR opportunity. Returns 3 candidate titles (configurable). The agent picks one and applies it via mwseo_set_seo_title. Optionally pass target_query to steer the candidates toward a specific search query the page already ranks for. Requires AI Engine.', |
| 868 |
'category' => 'SEO Engine', |
| 869 |
'accessLevel' => 'read', |
| 870 |
'inputSchema' => [ |
| 871 |
'type' => 'object', |
| 872 |
'properties' => [ |
| 873 |
'post_id' => [ |
| 874 |
'type' => 'integer', |
| 875 |
'description' => 'The post ID to suggest titles for.' |
| 876 |
], |
| 877 |
'target_query' => [ |
| 878 |
'type' => 'string', |
| 879 |
'description' => 'Optional. A search query the page should rank for. Candidates will naturally include this phrasing.' |
| 880 |
], |
| 881 |
'count' => [ |
| 882 |
'type' => 'integer', |
| 883 |
'description' => 'Number of candidate titles to return. Default 3, max 10.', |
| 884 |
'default' => 3 |
| 885 |
] |
| 886 |
], |
| 887 |
'required' => [ 'post_id' ] |
| 888 |
] |
| 889 |
]; |
| 890 |
|
| 891 |
$tools[] = [ |
| 892 |
'name' => 'mwseo_suggest_seo_excerpt', |
| 893 |
'description' => 'Generate AI-written meta description candidates for a post. The single biggest fixable issue bucket on most sites is missing or mis-sized meta descriptions; this pairs with mwseo_set_seo_excerpt to fix them in bulk (suggest, pick, set, then re-scan in batches). Returns 3 candidates (configurable). Optionally pass target_query to steer toward a query the page ranks for. Requires AI Engine.', |
| 894 |
'category' => 'SEO Engine', |
| 895 |
'accessLevel' => 'read', |
| 896 |
'inputSchema' => [ |
| 897 |
'type' => 'object', |
| 898 |
'properties' => [ |
| 899 |
'post_id' => [ |
| 900 |
'type' => 'integer', |
| 901 |
'description' => 'The post ID to suggest meta descriptions for.' |
| 902 |
], |
| 903 |
'target_query' => [ |
| 904 |
'type' => 'string', |
| 905 |
'description' => 'Optional. A search query the page should rank for. Candidates will naturally include this phrasing.' |
| 906 |
], |
| 907 |
'count' => [ |
| 908 |
'type' => 'integer', |
| 909 |
'description' => 'Number of candidates to return. Default 3, max 10.', |
| 910 |
'default' => 3 |
| 911 |
] |
| 912 |
], |
| 913 |
'required' => [ 'post_id' ] |
| 914 |
] |
| 915 |
]; |
| 916 |
|
| 917 |
$tools[] = [ |
| 918 |
'name' => 'mwseo_get_orphan_pages', |
| 919 |
'description' => 'Find published posts that have zero inbound internal links (no other post on the site links to them). Substantive orphans are the highest-value target for adding internal links. Filters keep the list actionable on large sites; defaults focus on substantive posts (>=300 words). Up to 2000 candidates scanned per call.', |
| 920 |
'category' => 'SEO Engine', |
| 921 |
'accessLevel' => 'read', |
| 922 |
'inputSchema' => [ |
| 923 |
'type' => 'object', |
| 924 |
'properties' => [ |
| 925 |
'post_type' => [ |
| 926 |
'type' => 'string', |
| 927 |
'description' => 'Post type to scan. Defaults to "post".', |
| 928 |
'default' => 'post' |
| 929 |
], |
| 930 |
'lang' => [ |
| 931 |
'type' => 'string', |
| 932 |
'description' => 'Optional. Polylang language slug (e.g. "en", "fr", "ja"). Only effective if Polylang is active.' |
| 933 |
], |
| 934 |
'created_after' => [ |
| 935 |
'type' => 'string', |
| 936 |
'description' => 'Optional. ISO date (YYYY-MM-DD). Only consider posts created on or after this date. Useful to skip very old content.' |
| 937 |
], |
| 938 |
'min_word_count' => [ |
| 939 |
'type' => 'integer', |
| 940 |
'description' => 'Skip posts below this word count. Default 300 (substantive posts only). CJK content is estimated via character count.', |
| 941 |
'default' => 300 |
| 942 |
], |
| 943 |
'limit' => [ |
| 944 |
'type' => 'integer', |
| 945 |
'description' => 'Maximum number of orphans to return, sorted by word count desc. Default 50, max 500.', |
| 946 |
'default' => 50 |
| 947 |
] |
| 948 |
] |
| 949 |
] |
| 950 |
]; |
| 951 |
|
| 952 |
$tools[] = [ |
| 953 |
'name' => 'mwseo_suggest_internal_links', |
| 954 |
'description' => 'Get AI-ranked internal-link candidate posts (no placement suggestions). FAST: extracts keywords, gathers candidates, AI-ranks the top matches, returns each with a context excerpt so you can judge relevance, usually 5 to 15 seconds total. To generate concrete placement options (search/replace snippets) for one of these candidates, call mwseo_generate_internal_link_placements afterwards with the source + chosen target. Accepts an existing post_id OR a draft_content payload. Requires Pro and AI Engine.', |
| 955 |
'category' => 'SEO Engine', |
| 956 |
'accessLevel' => 'read', |
| 957 |
'inputSchema' => [ |
| 958 |
'type' => 'object', |
| 959 |
'properties' => [ |
| 960 |
'post_id' => [ |
| 961 |
'type' => 'integer', |
| 962 |
'description' => 'WordPress post ID of an existing post. Either this OR draft_content must be provided.' |
| 963 |
], |
| 964 |
'draft_content' => [ |
| 965 |
'type' => 'object', |
| 966 |
'description' => 'Draft payload for unsaved content (use during drafting before publish). Either this OR post_id must be provided.', |
| 967 |
'properties' => [ |
| 968 |
'title' => [ 'type' => 'string', 'description' => 'Draft title' ], |
| 969 |
'content' => [ 'type' => 'string', 'description' => 'Draft body (HTML or plain text)' ], |
| 970 |
'post_type' => [ 'type' => 'string', 'description' => 'Optional. Defaults to "post".', 'default' => 'post' ] |
| 971 |
], |
| 972 |
'required' => [ 'title', 'content' ] |
| 973 |
], |
| 974 |
'max_candidates' => [ |
| 975 |
'type' => 'integer', |
| 976 |
'description' => 'Maximum candidates returned. Default 10, max 10.', |
| 977 |
'default' => 10 |
| 978 |
] |
| 979 |
] |
| 980 |
] |
| 981 |
]; |
| 982 |
|
| 983 |
$tools[] = [ |
| 984 |
'name' => 'mwseo_generate_internal_link_placements', |
| 985 |
'description' => 'Given a source post (or draft) and ONE target post, generate AI placement suggestions: each suggestion is a {search, replace, reason} triple ready to apply, using up to 4 strategies (link existing text → add in parenthesis → add new sentence → add at end). Usually 5-10 seconds. Call this after mwseo_suggest_internal_links to drill into the most promising candidate. Requires Pro and AI Engine.', |
| 986 |
'category' => 'SEO Engine', |
| 987 |
'accessLevel' => 'read', |
| 988 |
'inputSchema' => [ |
| 989 |
'type' => 'object', |
| 990 |
'properties' => [ |
| 991 |
'post_id' => [ |
| 992 |
'type' => 'integer', |
| 993 |
'description' => 'Source post ID. Either this OR draft_content must be provided.' |
| 994 |
], |
| 995 |
'draft_content' => [ |
| 996 |
'type' => 'object', |
| 997 |
'description' => 'Draft source payload. Either this OR post_id must be provided.', |
| 998 |
'properties' => [ |
| 999 |
'title' => [ 'type' => 'string' ], |
| 1000 |
'content' => [ 'type' => 'string' ], |
| 1001 |
'post_type' => [ 'type' => 'string', 'default' => 'post' ] |
| 1002 |
], |
| 1003 |
'required' => [ 'title', 'content' ] |
| 1004 |
], |
| 1005 |
'target_post_id' => [ |
| 1006 |
'type' => 'integer', |
| 1007 |
'description' => 'Target post ID (from a candidate returned by mwseo_suggest_internal_links).' |
| 1008 |
] |
| 1009 |
], |
| 1010 |
'required' => [ 'target_post_id' ] |
| 1011 |
] |
| 1012 |
]; |
| 1013 |
|
| 1014 |
// Google Search Console (Pro) |
| 1015 |
$tools[] = [ |
| 1016 |
'name' => 'mwseo_gsc_status', |
| 1017 |
'description' => 'Check Google Search Console connection status. Returns whether GSC is connected, the selected property, the list of available properties (verified sites the connected Google account owns), and the OAuth URL if not connected. ALWAYS call this first before other gsc_* tools. Requires Pro.', |
| 1018 |
'category' => 'SEO Engine', |
| 1019 |
'accessLevel' => 'read', |
| 1020 |
'inputSchema' => [ 'type' => 'object' ] |
| 1021 |
]; |
| 1022 |
|
| 1023 |
$tools[] = [ |
| 1024 |
'name' => 'mwseo_gsc_quick_wins', |
| 1025 |
'description' => 'THE high-leverage SEO tool: returns categorized, actionable opportunities with concrete payoff estimates and one-button-away next steps. Four categories: 🎯 Close to first page (posts ranking #5 to #15 with strong impressions: small lift, big traffic gain), 💡 Title & meta opportunities (high impressions but low CTR: the title or description isn\'t compelling clicks), 🤖 AI Overview suspects (top-5 queries with severely depressed CTR — likely cannibalized by Google\'s AI Overviews; Ahrefs measures ~60% CTR drop, Pew ~47%), 💎 Hidden gems (great CTR but few impressions: these posts convert when seen). Each opportunity includes the recommended action, the next MCP tool to run, and an estimated click lift. Requires Pro and a connected GSC property.', |
| 1026 |
'category' => 'SEO Engine', |
| 1027 |
'accessLevel' => 'read', |
| 1028 |
'inputSchema' => [ |
| 1029 |
'type' => 'object', |
| 1030 |
'properties' => [ |
| 1031 |
'days' => [ |
| 1032 |
'type' => 'integer', |
| 1033 |
'description' => 'Lookback window in days. Default 28, max 90.', |
| 1034 |
'default' => 28 |
| 1035 |
], |
| 1036 |
'min_impressions' => [ |
| 1037 |
'type' => 'integer', |
| 1038 |
'description' => 'Ignore queries with fewer impressions than this in the period (noise filter). Default 50.', |
| 1039 |
'default' => 50 |
| 1040 |
], |
| 1041 |
'limit_per_category' => [ |
| 1042 |
'type' => 'integer', |
| 1043 |
'description' => 'Maximum opportunities returned per category. Default 5, max 50.', |
| 1044 |
'default' => 5 |
| 1045 |
], |
| 1046 |
'property' => [ |
| 1047 |
'type' => 'string', |
| 1048 |
'description' => 'Optional. Override the active Search Console property (use the siteUrl from mwseo_gsc_status). Use this for multi-domain Polylang/WPML setups to query a specific language site.' |
| 1049 |
] |
| 1050 |
] |
| 1051 |
] |
| 1052 |
]; |
| 1053 |
|
| 1054 |
$tools[] = [ |
| 1055 |
'name' => 'mwseo_gsc_ai_overview_suspects', |
| 1056 |
'description' => 'Surfaces queries where this site ranks in the top 5 but clicks-through is severely below the position curve (under 35% of expected) — the signature pattern of Google\'s AI Overview cannibalizing the click. Independent studies measure AI Overviews dropping CTR 47% (Pew) to 60% (Ahrefs) on impacted queries. There is no direct fix that "beats" AI Overviews; the play is to be the source they cite, and to diversify traffic. Requires Pro and a connected GSC property.', |
| 1057 |
'category' => 'SEO Engine', |
| 1058 |
'accessLevel' => 'read', |
| 1059 |
'inputSchema' => [ |
| 1060 |
'type' => 'object', |
| 1061 |
'properties' => [ |
| 1062 |
'days' => [ |
| 1063 |
'type' => 'integer', |
| 1064 |
'description' => 'Lookback window in days. Default 28, max 90.', |
| 1065 |
'default' => 28 |
| 1066 |
], |
| 1067 |
'min_impressions' => [ |
| 1068 |
'type' => 'integer', |
| 1069 |
'description' => 'Ignore queries with fewer impressions than this in the period (noise filter). Default 50.', |
| 1070 |
'default' => 50 |
| 1071 |
], |
| 1072 |
'limit_per_category' => [ |
| 1073 |
'type' => 'integer', |
| 1074 |
'description' => 'Maximum suspects returned. Default 5, max 50.', |
| 1075 |
'default' => 5 |
| 1076 |
], |
| 1077 |
'property' => [ |
| 1078 |
'type' => 'string', |
| 1079 |
'description' => 'Optional. Override the active Search Console property (use the siteUrl from mwseo_gsc_status).' |
| 1080 |
] |
| 1081 |
] |
| 1082 |
] |
| 1083 |
]; |
| 1084 |
|
| 1085 |
$tools[] = [ |
| 1086 |
'name' => 'mwseo_gsc_site_pulse', |
| 1087 |
'description' => 'Site-level Search Console pulse: returns total clicks, impressions, average position, and CTR for the property over the period, plus period-over-period deltas. The "is the site growing?" answer in one call. Same trend shape as mwseo_gsc_post_pulse but at the site level. Requires Pro and a connected GSC property.', |
| 1088 |
'category' => 'SEO Engine', |
| 1089 |
'accessLevel' => 'read', |
| 1090 |
'inputSchema' => [ |
| 1091 |
'type' => 'object', |
| 1092 |
'properties' => [ |
| 1093 |
'days' => [ |
| 1094 |
'type' => 'integer', |
| 1095 |
'description' => 'Period length in days. The prior comparison period is the same length immediately before. Default 28.', |
| 1096 |
'default' => 28 |
| 1097 |
], |
| 1098 |
'property' => [ |
| 1099 |
'type' => 'string', |
| 1100 |
'description' => 'Optional. Override the active property (siteUrl). For multi-domain setups.' |
| 1101 |
] |
| 1102 |
] |
| 1103 |
] |
| 1104 |
]; |
| 1105 |
|
| 1106 |
$tools[] = [ |
| 1107 |
'name' => 'mwseo_gsc_weekly_digest', |
| 1108 |
'description' => 'One-call snapshot of a property: site totals + period-over-period trends, the top opportunity in each Quick Wins category, the pages that moved the most in clicks, and search queries that entered the data this period. Designed as the entry point for a weekly /pulse workflow — composes site_pulse + quick_wins + movers + new-query analysis into a single call. Requires Pro and a connected GSC property.', |
| 1109 |
'category' => 'SEO Engine', |
| 1110 |
'accessLevel' => 'read', |
| 1111 |
'inputSchema' => [ |
| 1112 |
'type' => 'object', |
| 1113 |
'properties' => [ |
| 1114 |
'days' => [ |
| 1115 |
'type' => 'integer', |
| 1116 |
'description' => 'Period length in days. The prior comparison period is the same length immediately before. Default 7 (one week).', |
| 1117 |
'default' => 7 |
| 1118 |
], |
| 1119 |
'property' => [ |
| 1120 |
'type' => 'string', |
| 1121 |
'description' => 'Optional. Override the active property (siteUrl). For multi-domain setups.' |
| 1122 |
] |
| 1123 |
] |
| 1124 |
] |
| 1125 |
]; |
| 1126 |
|
| 1127 |
$tools[] = [ |
| 1128 |
'name' => 'mwseo_gsc_post_pulse', |
| 1129 |
'description' => 'Per-post Search Console pulse: returns a one-line human headline ("This post has X impressions/month, ranking #N on average, clicks up Y%"), top queries the post ranks for, and trend comparisons (clicks/impressions/position) vs. the previous period. Designed for the "what\'s happening with my post" moment. Requires Pro and a connected GSC property.', |
| 1130 |
'category' => 'SEO Engine', |
| 1131 |
'accessLevel' => 'read', |
| 1132 |
'inputSchema' => [ |
| 1133 |
'type' => 'object', |
| 1134 |
'properties' => [ |
| 1135 |
'post_id' => [ |
| 1136 |
'type' => 'integer', |
| 1137 |
'description' => 'The WordPress post ID to inspect.' |
| 1138 |
], |
| 1139 |
'days' => [ |
| 1140 |
'type' => 'integer', |
| 1141 |
'description' => 'Current period length in days (the prior comparison period is the same length immediately before). Default 28.', |
| 1142 |
'default' => 28 |
| 1143 |
], |
| 1144 |
'property' => [ |
| 1145 |
'type' => 'string', |
| 1146 |
'description' => 'Optional. Override the active Search Console property (siteUrl). For multi-domain setups.' |
| 1147 |
], |
| 1148 |
'debug' => [ |
| 1149 |
'type' => 'boolean', |
| 1150 |
'description' => 'Optional. If true, include a _debug block in the response (code version + actual date windows queried). For verifying deployments and diagnosing empty-period issues.', |
| 1151 |
'default' => false |
| 1152 |
] |
| 1153 |
], |
| 1154 |
'required' => [ 'post_id' ] |
| 1155 |
] |
| 1156 |
]; |
| 1157 |
|
| 1158 |
$tools[] = [ |
| 1159 |
'name' => 'mwseo_gsc_top_queries', |
| 1160 |
'description' => 'Top search queries from Google Search Console, optionally scoped to a single page URL or country. Returns query, clicks, impressions, CTR, and average position. Use this for free-form exploration; use mwseo_gsc_quick_wins for actionable opportunities. Requires Pro and a connected GSC property.', |
| 1161 |
'category' => 'SEO Engine', |
| 1162 |
'accessLevel' => 'read', |
| 1163 |
'inputSchema' => [ |
| 1164 |
'type' => 'object', |
| 1165 |
'properties' => [ |
| 1166 |
'days' => [ 'type' => 'integer', 'description' => 'Lookback window in days. Default 28.', 'default' => 28 ], |
| 1167 |
'limit' => [ 'type' => 'integer', 'description' => 'Max queries to return. Default 25, max 1000.', 'default' => 25 ], |
| 1168 |
'page_url' => [ 'type' => 'string', 'description' => 'Optional. Full URL of a single page to scope the queries to.' ], |
| 1169 |
'country' => [ 'type' => 'string', 'description' => 'Optional. ISO 3-letter country code (e.g. "usa", "fra", "jpn").' ], |
| 1170 |
'property' => [ 'type' => 'string', 'description' => 'Optional. Override the active Search Console property (siteUrl). For multi-domain setups.' ] |
| 1171 |
] |
| 1172 |
] |
| 1173 |
]; |
| 1174 |
|
| 1175 |
$tools[] = [ |
| 1176 |
'name' => 'mwseo_gsc_top_pages', |
| 1177 |
'description' => 'Top pages by clicks from Google Search Console. Returns each page URL with clicks, impressions, CTR, average position, and the matched WP post_id/title where the URL resolves to a known post. Requires Pro and a connected GSC property.', |
| 1178 |
'category' => 'SEO Engine', |
| 1179 |
'accessLevel' => 'read', |
| 1180 |
'inputSchema' => [ |
| 1181 |
'type' => 'object', |
| 1182 |
'properties' => [ |
| 1183 |
'days' => [ 'type' => 'integer', 'description' => 'Lookback window in days. Default 28.', 'default' => 28 ], |
| 1184 |
'limit' => [ 'type' => 'integer', 'description' => 'Max pages to return. Default 25, max 1000.', 'default' => 25 ], |
| 1185 |
'property' => [ 'type' => 'string', 'description' => 'Optional. Override the active Search Console property (siteUrl). For multi-domain setups.' ] |
| 1186 |
] |
| 1187 |
] |
| 1188 |
]; |
| 1189 |
|
| 1190 |
$tools[] = [ |
| 1191 |
'name' => 'mwseo_gsc_set_property', |
| 1192 |
'description' => 'Set the active Google Search Console property to query. Use mwseo_gsc_status first to see available properties. The property is a verified site URL like "https://example.com/" or "sc-domain:example.com". Requires Pro and GSC connected.', |
| 1193 |
'category' => 'SEO Engine', |
| 1194 |
'accessLevel' => 'write', |
| 1195 |
'inputSchema' => [ |
| 1196 |
'type' => 'object', |
| 1197 |
'properties' => [ |
| 1198 |
'property' => [ 'type' => 'string', 'description' => 'The siteUrl from mwseo_gsc_status available_properties.' ] |
| 1199 |
], |
| 1200 |
'required' => [ 'property' ] |
| 1201 |
] |
| 1202 |
]; |
| 1203 |
|
| 1204 |
// Post Management |
| 1205 |
$tools[] = [ |
| 1206 |
'name' => 'mwseo_skip_post', |
| 1207 |
'description' => 'Mark a post to skip SEO analysis. Use this for posts that should not be analyzed (e.g., drafts, private pages, or content you do not want indexed). The post will be excluded from SEO scoring and reports.', |
| 1208 |
'category' => 'SEO Engine', |
| 1209 |
'accessLevel' => 'write', |
| 1210 |
'inputSchema' => [ |
| 1211 |
'type' => 'object', |
| 1212 |
'properties' => [ |
| 1213 |
'post_id' => [ |
| 1214 |
'type' => 'integer', |
| 1215 |
'description' => 'The WordPress post ID to skip' |
| 1216 |
], |
| 1217 |
'skip' => [ |
| 1218 |
'type' => 'boolean', |
| 1219 |
'description' => 'True to skip the post, false to un-skip it', |
| 1220 |
'default' => true |
| 1221 |
] |
| 1222 |
], |
| 1223 |
'required' => ['post_id'] |
| 1224 |
] |
| 1225 |
]; |
| 1226 |
|
| 1227 |
// AI Visibility (Pro) — how brands/products are recommended by AI assistants. |
| 1228 |
$aiVisibility = $this->core->ai_visibility(); |
| 1229 |
if ( $aiVisibility && $aiVisibility->is_enabled() ) { |
| 1230 |
$tools[] = [ |
| 1231 |
'name' => 'mwseo_ai_visibility_brands', |
| 1232 |
'description' => 'List the brands or products tracked by AI Visibility, with their AI Visibility score (0-100), their rank on each AI provider (OpenAI, Anthropic, Google...), how many checks mentioned them, sentiment, and when they were last scanned. Use this to answer "how visible is my brand in AI assistants" or to find which tracked products are invisible to AI search.', |
| 1233 |
'category' => 'SEO Engine', |
| 1234 |
'accessLevel' => 'read', |
| 1235 |
'inputSchema' => [ 'type' => 'object' ] |
| 1236 |
]; |
| 1237 |
|
| 1238 |
$tools[] = [ |
| 1239 |
'name' => 'mwseo_ai_visibility_detail', |
| 1240 |
'description' => 'Get the full AI Visibility breakdown for one tracked brand: every question tested, whether each AI provider mentioned the brand and at what rank, the competitors recommended alongside it, and the trend over past scans. Use this to understand WHY a brand ranks well or poorly, and which questions it is missing from.', |
| 1241 |
'category' => 'SEO Engine', |
| 1242 |
'accessLevel' => 'read', |
| 1243 |
'inputSchema' => [ |
| 1244 |
'type' => 'object', |
| 1245 |
'properties' => [ |
| 1246 |
'brand_id' => [ |
| 1247 |
'type' => 'integer', |
| 1248 |
'description' => 'The tracked brand ID, from mwseo_ai_visibility_brands.' |
| 1249 |
] |
| 1250 |
], |
| 1251 |
'required' => [ 'brand_id' ] |
| 1252 |
] |
| 1253 |
]; |
| 1254 |
|
| 1255 |
$tools[] = [ |
| 1256 |
'name' => 'mwseo_ai_visibility_suggest_questions', |
| 1257 |
'description' => 'Propose the buyer-intent questions worth testing for a brand or product, without creating anything. Questions are about the category (e.g. "best plugin to find unused media in WordPress"), never about the brand by name. Use this to review a question set before adding a brand, or to hand a list to a human to paste into the admin.', |
| 1258 |
'category' => 'SEO Engine', |
| 1259 |
'accessLevel' => 'read', |
| 1260 |
'inputSchema' => [ |
| 1261 |
'type' => 'object', |
| 1262 |
'properties' => [ |
| 1263 |
'name' => [ |
| 1264 |
'type' => 'string', |
| 1265 |
'description' => 'The brand or product name, e.g. "Media Cleaner".' |
| 1266 |
], |
| 1267 |
'description' => [ |
| 1268 |
'type' => 'string', |
| 1269 |
'description' => 'What the product does, in one or two sentences. This matters: the name alone rarely says what people would actually ask an assistant for.' |
| 1270 |
], |
| 1271 |
'competitors' => [ |
| 1272 |
'type' => 'array', |
| 1273 |
'items' => [ 'type' => 'string' ], |
| 1274 |
'description' => 'Known competing products, to steer the category framing.' |
| 1275 |
] |
| 1276 |
], |
| 1277 |
'required' => [ 'name' ] |
| 1278 |
] |
| 1279 |
]; |
| 1280 |
|
| 1281 |
$tools[] = [ |
| 1282 |
'name' => 'mwseo_ai_visibility_add_brand', |
| 1283 |
'description' => 'Start tracking a brand or product in AI Visibility. Creates it with a set of questions (yours, or generated from the name and description when you pass none) and returns the brand_id. Adding a brand does not scan it: run mwseo_ai_visibility_scan afterwards to get a score.', |
| 1284 |
'category' => 'SEO Engine', |
| 1285 |
'accessLevel' => 'write', |
| 1286 |
'inputSchema' => [ |
| 1287 |
'type' => 'object', |
| 1288 |
'properties' => [ |
| 1289 |
'name' => [ |
| 1290 |
'type' => 'string', |
| 1291 |
'description' => 'The brand or product name, exactly as people would write it.' |
| 1292 |
], |
| 1293 |
'url' => [ |
| 1294 |
'type' => 'string', |
| 1295 |
'description' => 'The product page or site URL.' |
| 1296 |
], |
| 1297 |
'description' => [ |
| 1298 |
'type' => 'string', |
| 1299 |
'description' => 'What the product does, in one or two sentences. Used to generate the questions when none are given.' |
| 1300 |
], |
| 1301 |
'competitors' => [ |
| 1302 |
'type' => 'array', |
| 1303 |
'items' => [ 'type' => 'string' ], |
| 1304 |
'description' => 'Known competing products to watch for in the answers.' |
| 1305 |
], |
| 1306 |
'questions' => [ |
| 1307 |
'type' => 'array', |
| 1308 |
'items' => [ 'type' => 'string' ], |
| 1309 |
'description' => 'The questions to test, about the category rather than the brand. When omitted, a set is generated automatically.' |
| 1310 |
] |
| 1311 |
], |
| 1312 |
'required' => [ 'name' ] |
| 1313 |
] |
| 1314 |
]; |
| 1315 |
|
| 1316 |
$tools[] = [ |
| 1317 |
'name' => 'mwseo_ai_visibility_scan', |
| 1318 |
'description' => 'Scan a tracked brand: ask every question on every configured AI provider and record whether the brand was recommended, at what rank, and against which competitors. This spends real money (one AI call per question per provider), so the first call is always a dry run reporting the plan and the estimated cost, and nothing runs until you call again with confirm set to true. Scanning is chunked so it never times out: each call runs a few units and returns the batch and next_offset to pass back, until remaining reaches 0 and the score is finalized.', |
| 1319 |
'category' => 'SEO Engine', |
| 1320 |
'accessLevel' => 'write', |
| 1321 |
'inputSchema' => [ |
| 1322 |
'type' => 'object', |
| 1323 |
'properties' => [ |
| 1324 |
'brand_id' => [ |
| 1325 |
'type' => 'integer', |
| 1326 |
'description' => 'The tracked brand ID, from mwseo_ai_visibility_brands.' |
| 1327 |
], |
| 1328 |
'confirm' => [ |
| 1329 |
'type' => 'boolean', |
| 1330 |
'description' => 'Must be true to actually spend AI calls. Without it the tool only reports the plan and the estimated cost.', |
| 1331 |
'default' => false |
| 1332 |
], |
| 1333 |
'batch' => [ |
| 1334 |
'type' => 'string', |
| 1335 |
'description' => 'Continue an in-progress scan: the batch returned by the previous call. Omit to start a new scan.' |
| 1336 |
], |
| 1337 |
'offset' => [ |
| 1338 |
'type' => 'integer', |
| 1339 |
'description' => 'Continue an in-progress scan: the next_offset returned by the previous call.', |
| 1340 |
'default' => 0 |
| 1341 |
], |
| 1342 |
'max_units' => [ |
| 1343 |
'type' => 'integer', |
| 1344 |
'description' => 'How many question-by-provider units to run in this call (1 to 20, default 6). Higher is faster but risks a request timeout.', |
| 1345 |
'default' => 6 |
| 1346 |
] |
| 1347 |
], |
| 1348 |
'required' => [ 'brand_id' ] |
| 1349 |
] |
| 1350 |
]; |
| 1351 |
|
| 1352 |
$tools[] = [ |
| 1353 |
'name' => 'mwseo_ai_visibility_delete_brand', |
| 1354 |
'description' => 'Stop tracking a brand and permanently delete it with all of its scan history. Call once without confirm to see what would be deleted, then again with confirm set to true.', |
| 1355 |
'category' => 'SEO Engine', |
| 1356 |
'accessLevel' => 'write', |
| 1357 |
'inputSchema' => [ |
| 1358 |
'type' => 'object', |
| 1359 |
'properties' => [ |
| 1360 |
'brand_id' => [ |
| 1361 |
'type' => 'integer', |
| 1362 |
'description' => 'The tracked brand ID, from mwseo_ai_visibility_brands.' |
| 1363 |
], |
| 1364 |
'confirm' => [ |
| 1365 |
'type' => 'boolean', |
| 1366 |
'description' => 'Must be true to actually delete. Without it the tool only reports what would be removed.', |
| 1367 |
'default' => false |
| 1368 |
] |
| 1369 |
], |
| 1370 |
'required' => [ 'brand_id' ] |
| 1371 |
] |
| 1372 |
]; |
| 1373 |
} |
| 1374 |
|
| 1375 |
return $tools; |
| 1376 |
} |
| 1377 |
|
| 1378 |
public function handle_tool_execution( $result, $tool, $args, $id ) { |
| 1379 |
// Only handle our tools |
| 1380 |
if ( strpos( $tool, 'mwseo_' ) !== 0 ) { |
| 1381 |
return $result; |
| 1382 |
} |
| 1383 |
|
| 1384 |
// Accept common aliases for the post id. These tools use "post_id", while |
| 1385 |
// AI Engine's wp_* post tools use the WordPress-native "ID". Agents hopping |
| 1386 |
// between suites guess the wrong spelling; mirror the variants so either |
| 1387 |
// works. post_id is the only post identifier here, so this is safe. |
| 1388 |
if ( is_array( $args ) ) { |
| 1389 |
$idAliases = [ 'post_id', 'ID', 'id' ]; |
| 1390 |
$primaryId = null; |
| 1391 |
foreach ( $idAliases as $k ) { |
| 1392 |
if ( isset( $args[ $k ] ) && $args[ $k ] !== '' ) { |
| 1393 |
$primaryId = $args[ $k ]; |
| 1394 |
break; |
| 1395 |
} |
| 1396 |
} |
| 1397 |
if ( $primaryId !== null ) { |
| 1398 |
foreach ( $idAliases as $k ) { |
| 1399 |
if ( !isset( $args[ $k ] ) || $args[ $k ] === '' ) { |
| 1400 |
$args[ $k ] = $primaryId; |
| 1401 |
} |
| 1402 |
} |
| 1403 |
} |
| 1404 |
} |
| 1405 |
|
| 1406 |
// Ensure API is initialized |
| 1407 |
if ( !$this->api ) { |
| 1408 |
return [ 'success' => false, 'error' => 'SEO Engine API not initialized' ]; |
| 1409 |
} |
| 1410 |
|
| 1411 |
try { |
| 1412 |
switch ( $tool ) { |
| 1413 |
// SEO Title Operations |
| 1414 |
case 'mwseo_get_seo_title': |
| 1415 |
return $this->api->get_seo_title( $args['post_id'] ); |
| 1416 |
|
| 1417 |
case 'mwseo_set_seo_title': |
| 1418 |
return $this->api->set_seo_title( $args['post_id'], $args['title'] ); |
| 1419 |
|
| 1420 |
// SEO Excerpt Operations |
| 1421 |
case 'mwseo_get_seo_excerpt': |
| 1422 |
return $this->api->get_seo_excerpt( $args['post_id'] ); |
| 1423 |
|
| 1424 |
case 'mwseo_set_seo_excerpt': |
| 1425 |
return $this->api->set_seo_excerpt( $args['post_id'], $args['excerpt'] ); |
| 1426 |
|
| 1427 |
// SEO Score Operations |
| 1428 |
case 'mwseo_get_seo_score': |
| 1429 |
$post = get_post( $args['post_id'] ); |
| 1430 |
if ( !$post ) { |
| 1431 |
return [ 'success' => false, 'error' => 'Post not found' ]; |
| 1432 |
} |
| 1433 |
|
| 1434 |
// Get basic score info |
| 1435 |
$score_data = $this->api->get_seo_score( $args['post_id'] ); |
| 1436 |
|
| 1437 |
// Get full analysis data |
| 1438 |
$analysis = get_post_meta( $post->ID, '_mwseo_analysis', true ); |
| 1439 |
|
| 1440 |
if ( $analysis && isset( $analysis['tests'] ) ) { |
| 1441 |
$score_data['analysis'] = $analysis; |
| 1442 |
} |
| 1443 |
|
| 1444 |
return [ 'success' => true, 'data' => $score_data ]; |
| 1445 |
|
| 1446 |
case 'mwseo_do_seo_scan': |
| 1447 |
return $this->api->do_seo_scan( $args['post_id'] ); |
| 1448 |
|
| 1449 |
case 'mwseo_get_scored_posts': |
| 1450 |
// Note: get_scored_posts() returns a bare array, not a success wrapper |
| 1451 |
$posts = $this->api->get_scored_posts(); |
| 1452 |
|
| 1453 |
if ( !is_array( $posts ) ) { |
| 1454 |
return [ 'success' => false, 'error' => 'Failed to retrieve scored posts' ]; |
| 1455 |
} |
| 1456 |
|
| 1457 |
// Apply filters if provided |
| 1458 |
if ( isset( $args['post_type'] ) && !empty( $args['post_type'] ) ) { |
| 1459 |
$posts = array_filter( $posts, function( $post ) use ( $args ) { |
| 1460 |
$post_obj = get_post( $post['id'] ); |
| 1461 |
return $post_obj && $post_obj->post_type === $args['post_type']; |
| 1462 |
}); |
| 1463 |
} |
| 1464 |
|
| 1465 |
if ( isset( $args['status'] ) && !empty( $args['status'] ) ) { |
| 1466 |
$posts = array_filter( $posts, function( $post ) use ( $args ) { |
| 1467 |
return isset( $post['status'] ) && $post['status'] === $args['status']; |
| 1468 |
}); |
| 1469 |
} |
| 1470 |
|
| 1471 |
// Apply limit |
| 1472 |
$limit = $args['limit'] ?? 100; |
| 1473 |
$posts = array_slice( $posts, 0, $limit ); |
| 1474 |
|
| 1475 |
return [ 'success' => true, 'data' => array_values( $posts ) ]; |
| 1476 |
|
| 1477 |
// Insights |
| 1478 |
case 'mwseo_get_insights': |
| 1479 |
return $this->api->get_insights( $args['post_id'] ); |
| 1480 |
|
| 1481 |
// Robots.txt Operations |
| 1482 |
case 'mwseo_get_robots_txt': |
| 1483 |
$content = $this->api->get_robots_txt(); |
| 1484 |
return [ 'success' => true, 'data' => [ 'content' => $content ] ]; |
| 1485 |
|
| 1486 |
case 'mwseo_set_robots_txt': |
| 1487 |
$result = $this->api->set_robots_txt( $args['content'] ); |
| 1488 |
return [ 'success' => true, 'data' => $result ]; |
| 1489 |
|
| 1490 |
// Analytics Operations (Source-Agnostic) |
| 1491 |
case 'mwseo_get_analytics_data': |
| 1492 |
// metric is required - validate it exists |
| 1493 |
if ( empty( $args['metric'] ) ) { |
| 1494 |
return [ 'success' => false, 'error' => 'metric parameter is required. Must be "summary" or "top_posts".' ]; |
| 1495 |
} |
| 1496 |
|
| 1497 |
$metric = $args['metric']; |
| 1498 |
$start_date = $args['start_date'] ?? null; |
| 1499 |
$end_date = $args['end_date'] ?? null; |
| 1500 |
$country = $args['country'] ?? null; |
| 1501 |
$limit = $args['limit'] ?? 20; |
| 1502 |
|
| 1503 |
if ( $metric === 'summary' ) { |
| 1504 |
// Use source-agnostic method (respects Display Source setting) |
| 1505 |
$data = $this->core->get_analytics_summary( $start_date, $end_date ); |
| 1506 |
return [ 'success' => !empty( $data ), 'data' => $data ]; |
| 1507 |
} |
| 1508 |
elseif ( $metric === 'top_posts' ) { |
| 1509 |
// Use source-agnostic method (respects Display Source setting) |
| 1510 |
$query_args = [ |
| 1511 |
'start_date' => $start_date, |
| 1512 |
'end_date' => $end_date, |
| 1513 |
'limit' => $limit |
| 1514 |
]; |
| 1515 |
|
| 1516 |
$top_posts = $this->core->get_top_posts( $query_args ); |
| 1517 |
|
| 1518 |
// Filter by country if specified (only works with Google Analytics data) |
| 1519 |
if ( !empty( $country ) && $country !== 'all' && is_array( $top_posts ) ) { |
| 1520 |
$top_posts = array_filter( $top_posts, function( $post ) use ( $country ) { |
| 1521 |
return isset( $post['country'] ) && $post['country'] === $country; |
| 1522 |
}); |
| 1523 |
$top_posts = array_values( $top_posts ); |
| 1524 |
} |
| 1525 |
|
| 1526 |
return [ 'success' => !empty( $top_posts ), 'data' => $top_posts ]; |
| 1527 |
} |
| 1528 |
else { |
| 1529 |
return [ 'success' => false, 'error' => 'Invalid metric. Must be "summary" or "top_posts".' ]; |
| 1530 |
} |
| 1531 |
|
| 1532 |
case 'mwseo_get_post_analytics': |
| 1533 |
if ( empty( $args['post_id'] ) ) { |
| 1534 |
return [ 'success' => false, 'error' => 'post_id parameter is required.' ]; |
| 1535 |
} |
| 1536 |
|
| 1537 |
$post_id = (int) $args['post_id']; |
| 1538 |
$post = get_post( $post_id ); |
| 1539 |
if ( !$post ) { |
| 1540 |
return [ 'success' => false, 'error' => 'Post not found with ID ' . $post_id . '.' ]; |
| 1541 |
} |
| 1542 |
|
| 1543 |
$permalink = get_permalink( $post_id ); |
| 1544 |
$page_path = wp_parse_url( $permalink, PHP_URL_PATH ) ?: '/'; |
| 1545 |
$start_date = $args['start_date'] ?? null; |
| 1546 |
$end_date = $args['end_date'] ?? null; |
| 1547 |
|
| 1548 |
$data = $this->core->get_post_analytics( $post_id, $page_path, $start_date, $end_date ); |
| 1549 |
|
| 1550 |
if ( empty( $data ) ) { |
| 1551 |
return [ |
| 1552 |
'success' => true, |
| 1553 |
'data' => [ |
| 1554 |
'post_id' => $post_id, |
| 1555 |
'post_title' => $post->post_title, |
| 1556 |
'post_url' => $permalink, |
| 1557 |
'message' => 'No analytics data found for this post in the selected date range.' |
| 1558 |
] |
| 1559 |
]; |
| 1560 |
} |
| 1561 |
|
| 1562 |
$data['post_id'] = $post_id; |
| 1563 |
$data['post_title'] = $post->post_title; |
| 1564 |
$data['post_url'] = $permalink; |
| 1565 |
|
| 1566 |
return [ 'success' => true, 'data' => $data ]; |
| 1567 |
|
| 1568 |
case 'mwseo_get_analytics_top_countries': |
| 1569 |
// Use source-agnostic method (respects Display Source setting) |
| 1570 |
$top_posts = $this->core->get_top_posts( [] ); |
| 1571 |
|
| 1572 |
if ( empty( $top_posts ) || !is_array( $top_posts ) ) { |
| 1573 |
return [ 'success' => false, 'data' => [] ]; |
| 1574 |
} |
| 1575 |
|
| 1576 |
// Aggregate visitor counts by country (if country data is available) |
| 1577 |
$country_stats = []; |
| 1578 |
foreach ( $top_posts as $post ) { |
| 1579 |
if ( isset( $post['country'] ) ) { |
| 1580 |
$country = $post['country']; |
| 1581 |
// Use unique_visitors if available, fallback to visits, then to 1 |
| 1582 |
$visitors = isset( $post['unique_visitors'] ) ? (int) $post['unique_visitors'] : |
| 1583 |
(isset( $post['visits'] ) ? (int) $post['visits'] : 1); |
| 1584 |
|
| 1585 |
if ( !isset( $country_stats[$country] ) ) { |
| 1586 |
$country_stats[$country] = [ |
| 1587 |
'country' => $country, |
| 1588 |
'visitors' => 0 |
| 1589 |
]; |
| 1590 |
} |
| 1591 |
$country_stats[$country]['visitors'] += $visitors; |
| 1592 |
} |
| 1593 |
} |
| 1594 |
|
| 1595 |
// If no country data found (e.g., Private Analytics), return error |
| 1596 |
if ( empty( $country_stats ) ) { |
| 1597 |
return [ |
| 1598 |
'success' => false, |
| 1599 |
'error' => 'Country data not available with current analytics source. This feature requires Google Analytics or Plausible Analytics.', |
| 1600 |
'data' => [] |
| 1601 |
]; |
| 1602 |
} |
| 1603 |
|
| 1604 |
// Sort by visitor count descending |
| 1605 |
usort( $country_stats, function( $a, $b ) { |
| 1606 |
return $b['visitors'] - $a['visitors']; |
| 1607 |
}); |
| 1608 |
|
| 1609 |
return [ 'success' => true, 'data' => array_values( $country_stats ) ]; |
| 1610 |
|
| 1611 |
// Utility Tools |
| 1612 |
case 'mwseo_get_post_by_slug': |
| 1613 |
$post = get_page_by_path( |
| 1614 |
$args['slug'], |
| 1615 |
OBJECT, |
| 1616 |
$args['post_type'] ?? 'post' |
| 1617 |
); |
| 1618 |
if ( $post ) { |
| 1619 |
return [ |
| 1620 |
'success' => true, |
| 1621 |
'data' => [ |
| 1622 |
'post_id' => $post->ID, |
| 1623 |
'post_title' => $post->post_title, |
| 1624 |
'post_type' => $post->post_type |
| 1625 |
] |
| 1626 |
]; |
| 1627 |
} |
| 1628 |
return [ 'success' => false, 'error' => 'Post not found' ]; |
| 1629 |
|
| 1630 |
case 'mwseo_bulk_seo_scan': { |
| 1631 |
// Bulk runs QUICK scans: sub-second each, they refresh the failing-test counts after |
| 1632 |
// content fixes (the actual bulk use case) and preserve existing AI results. Full AI |
| 1633 |
// analysis takes seconds per post and times out in bulk, so it stays per-post via |
| 1634 |
// mwseo_do_seo_scan. The batch is capped and the rest handed back explicitly instead |
| 1635 |
// of timing out halfway with no explanation. |
| 1636 |
$requested = array_values( array_map( 'intval', (array) $args['post_ids'] ) ); |
| 1637 |
$batch = array_slice( $requested, 0, 20 ); |
| 1638 |
$skipped = array_slice( $requested, 20 ); |
| 1639 |
$results = []; |
| 1640 |
foreach ( $batch as $post_id ) { |
| 1641 |
$post = get_post( $post_id ); |
| 1642 |
$results[$post_id] = $post |
| 1643 |
? $this->core->calculate_seo_score( $post, 'quick' ) |
| 1644 |
: [ 'success' => false, 'message' => 'Post not found.' ]; |
| 1645 |
} |
| 1646 |
$response = [ 'success' => true, 'mode' => 'quick', 'data' => $results ]; |
| 1647 |
if ( !empty( $skipped ) ) { |
| 1648 |
$response['skipped'] = $skipped; |
| 1649 |
$response['note'] = 'Only 20 posts are scanned per call to stay within HTTP timeouts. Call again with the skipped IDs. For a full AI re-analysis of one post, use mwseo_do_seo_scan.'; |
| 1650 |
} |
| 1651 |
return $response; |
| 1652 |
} |
| 1653 |
|
| 1654 |
// Advanced SEO Tools |
| 1655 |
case 'mwseo_get_posts_by_score_range': |
| 1656 |
// Note: get_scored_posts() returns a bare array, not a success wrapper |
| 1657 |
$posts = $this->api->get_scored_posts(); |
| 1658 |
|
| 1659 |
if ( !is_array( $posts ) ) { |
| 1660 |
return [ 'success' => false, 'error' => 'Failed to retrieve scored posts' ]; |
| 1661 |
} |
| 1662 |
|
| 1663 |
$filtered = array_filter( $posts, function( $post ) use ( $args ) { |
| 1664 |
$score = $post['score'] ?? 0; |
| 1665 |
return $score >= $args['min_score'] && $score <= $args['max_score']; |
| 1666 |
}); |
| 1667 |
|
| 1668 |
return [ 'success' => true, 'data' => array_values( $filtered ) ]; |
| 1669 |
|
| 1670 |
case 'mwseo_get_posts_missing_seo': |
| 1671 |
// TODO: meta_key_seo_title and meta_key_seo_excerpt should migrate to _mwseo_title and _mwseo_excerpt |
| 1672 |
$query_args = [ |
| 1673 |
'post_type' => !empty($args['post_type']) ? $args['post_type'] : ['post', 'page'], |
| 1674 |
'posts_per_page' => $args['limit'] ?? 50, |
| 1675 |
'meta_query' => [ |
| 1676 |
'relation' => 'OR', |
| 1677 |
[ |
| 1678 |
'key' => $this->core->meta_key_seo_title, |
| 1679 |
'compare' => 'NOT EXISTS' |
| 1680 |
], |
| 1681 |
[ |
| 1682 |
'key' => $this->core->meta_key_seo_excerpt, |
| 1683 |
'compare' => 'NOT EXISTS' |
| 1684 |
] |
| 1685 |
] |
| 1686 |
]; |
| 1687 |
|
| 1688 |
$posts = get_posts( $query_args ); |
| 1689 |
$results = []; |
| 1690 |
|
| 1691 |
foreach ( $posts as $post ) { |
| 1692 |
$results[] = [ |
| 1693 |
'post_id' => $post->ID, |
| 1694 |
'post_title' => $post->post_title, |
| 1695 |
'post_type' => $post->post_type, |
| 1696 |
'permalink' => get_permalink( $post->ID ), |
| 1697 |
'missing_title' => !get_post_meta( $post->ID, $this->core->meta_key_seo_title, true ), |
| 1698 |
'missing_excerpt' => !get_post_meta( $post->ID, $this->core->meta_key_seo_excerpt, true ) |
| 1699 |
]; |
| 1700 |
} |
| 1701 |
|
| 1702 |
return [ 'success' => true, 'data' => $results ]; |
| 1703 |
|
| 1704 |
case 'mwseo_get_posts_needing_seo': |
| 1705 |
// Find posts where the EFFECTIVE SEO (custom or auto-generated) has actual problems |
| 1706 |
$post_type = !empty( $args['post_type'] ) ? $args['post_type'] : ['post', 'page']; |
| 1707 |
$issue_type = $args['issue_type'] ?? 'any'; |
| 1708 |
$limit = $args['limit'] ?? 50; |
| 1709 |
|
| 1710 |
$results = []; |
| 1711 |
// Every post has to be evaluated to know whether it has an issue, so this walks |
| 1712 |
// the whole site in chunks and stops as soon as $limit matches are collected. |
| 1713 |
foreach ( $this->each_post( [ 'post_type' => $post_type, 'post_status' => 'publish' ] ) as $post ) { |
| 1714 |
$seo_eval = $this->evaluate_effective_seo( $post ); |
| 1715 |
|
| 1716 |
// Filter by issue type |
| 1717 |
$has_relevant_issue = false; |
| 1718 |
if ( $issue_type === 'title' && !empty( $seo_eval['title_issues'] ) ) { |
| 1719 |
$has_relevant_issue = true; |
| 1720 |
} elseif ( $issue_type === 'description' && !empty( $seo_eval['description_issues'] ) ) { |
| 1721 |
$has_relevant_issue = true; |
| 1722 |
} elseif ( $issue_type === 'any' && $seo_eval['needs_attention'] ) { |
| 1723 |
$has_relevant_issue = true; |
| 1724 |
} |
| 1725 |
|
| 1726 |
if ( $has_relevant_issue ) { |
| 1727 |
$results[] = [ |
| 1728 |
'post_id' => $post->ID, |
| 1729 |
'post_title' => $post->post_title, |
| 1730 |
'post_type' => $post->post_type, |
| 1731 |
'permalink' => get_permalink( $post->ID ), |
| 1732 |
'effective_title' => $seo_eval['effective_title'], |
| 1733 |
'effective_description' => $seo_eval['effective_description'], |
| 1734 |
'title_display_width' => $seo_eval['title_display_width'], |
| 1735 |
'description_display_width' => $seo_eval['description_display_width'], |
| 1736 |
'title_issues' => $seo_eval['title_issues'], |
| 1737 |
'description_issues' => $seo_eval['description_issues'], |
| 1738 |
'has_custom_title' => $seo_eval['has_custom_title'], |
| 1739 |
'has_custom_description' => $seo_eval['has_custom_description'] |
| 1740 |
]; |
| 1741 |
|
| 1742 |
if ( count( $results ) >= $limit ) { |
| 1743 |
break; |
| 1744 |
} |
| 1745 |
} |
| 1746 |
} |
| 1747 |
|
| 1748 |
return [ 'success' => true, 'data' => $results ]; |
| 1749 |
|
| 1750 |
case 'mwseo_search_posts': |
| 1751 |
// Empty search_term silently returned recent posts via get_posts('s'=>''). |
| 1752 |
// Validate input and run a direct LIKE query for predictable matching. |
| 1753 |
$search_term = isset( $args['search_term'] ) ? trim( (string) $args['search_term'] ) : ''; |
| 1754 |
if ( $search_term === '' ) { |
| 1755 |
return [ 'success' => false, 'error' => 'search_term is required and cannot be empty' ]; |
| 1756 |
} |
| 1757 |
|
| 1758 |
$limit = isset( $args['limit'] ) ? max( 1, min( 100, (int) $args['limit'] ) ) : 20; |
| 1759 |
$post_types = !empty( $args['post_type'] ) ? (array) $args['post_type'] : [ 'post', 'page' ]; |
| 1760 |
|
| 1761 |
global $wpdb; |
| 1762 |
$like = '%' . $wpdb->esc_like( $search_term ) . '%'; |
| 1763 |
$type_placeholders = implode( ',', array_fill( 0, count( $post_types ), '%s' ) ); |
| 1764 |
|
| 1765 |
$sql = "SELECT ID FROM {$wpdb->posts} |
| 1766 |
WHERE post_status = 'publish' |
| 1767 |
AND post_type IN ($type_placeholders) |
| 1768 |
AND ( post_title LIKE %s OR post_content LIKE %s OR post_excerpt LIKE %s ) |
| 1769 |
ORDER BY |
| 1770 |
CASE WHEN post_title LIKE %s THEN 0 ELSE 1 END, |
| 1771 |
post_date DESC |
| 1772 |
LIMIT %d"; |
| 1773 |
|
| 1774 |
$params = array_merge( $post_types, [ $like, $like, $like, $like, $limit ] ); |
| 1775 |
$post_ids = $wpdb->get_col( $wpdb->prepare( $sql, $params ) ); |
| 1776 |
|
| 1777 |
$results = []; |
| 1778 |
foreach ( $post_ids as $post_id ) { |
| 1779 |
$post = get_post( $post_id ); |
| 1780 |
if ( !$post ) continue; |
| 1781 |
$results[] = [ |
| 1782 |
'post_id' => $post->ID, |
| 1783 |
'post_title' => $post->post_title, |
| 1784 |
'post_type' => $post->post_type, |
| 1785 |
'permalink' => get_permalink( $post->ID ), |
| 1786 |
'seo_score' => get_post_meta( $post->ID, '_mwseo_score', true ) ?: null |
| 1787 |
]; |
| 1788 |
} |
| 1789 |
|
| 1790 |
return [ 'success' => true, 'data' => $results ]; |
| 1791 |
|
| 1792 |
case 'mwseo_get_recent_posts': |
| 1793 |
$date_query = [ |
| 1794 |
[ |
| 1795 |
'after' => $args['days'] . ' days ago', |
| 1796 |
'inclusive' => true |
| 1797 |
] |
| 1798 |
]; |
| 1799 |
|
| 1800 |
$query_args = [ |
| 1801 |
'post_type' => $args['post_type'] ?? 'post', |
| 1802 |
'date_query' => $date_query, |
| 1803 |
'orderby' => 'date', |
| 1804 |
'order' => 'DESC' |
| 1805 |
]; |
| 1806 |
|
| 1807 |
$results = []; |
| 1808 |
|
| 1809 |
foreach ( $this->each_post( $query_args ) as $post ) { |
| 1810 |
$results[] = [ |
| 1811 |
'post_id' => $post->ID, |
| 1812 |
'post_title' => $post->post_title, |
| 1813 |
'post_date' => $post->post_date, |
| 1814 |
'permalink' => get_permalink( $post->ID ), |
| 1815 |
'seo_score' => get_post_meta( $post->ID, '_mwseo_score', true ) ?: null, |
| 1816 |
'has_seo_title' => (bool) get_post_meta( $post->ID, $this->core->meta_key_seo_title, true ), |
| 1817 |
'has_seo_excerpt' => (bool) get_post_meta( $post->ID, $this->core->meta_key_seo_excerpt, true ) |
| 1818 |
]; |
| 1819 |
} |
| 1820 |
|
| 1821 |
return [ 'success' => true, 'data' => $results ]; |
| 1822 |
|
| 1823 |
case 'mwseo_generate_sitemap_preview': |
| 1824 |
$query_args = [ |
| 1825 |
'post_type' => !empty($args['post_type']) ? $args['post_type'] : ['post', 'page'], |
| 1826 |
'posts_per_page' => $args['limit'] ?? 100, |
| 1827 |
'post_status' => 'publish', |
| 1828 |
'orderby' => 'modified', |
| 1829 |
'order' => 'DESC' |
| 1830 |
]; |
| 1831 |
|
| 1832 |
$posts = get_posts( $query_args ); |
| 1833 |
$urls = []; |
| 1834 |
|
| 1835 |
foreach ( $posts as $post ) { |
| 1836 |
$urls[] = [ |
| 1837 |
'loc' => get_permalink( $post->ID ), |
| 1838 |
'lastmod' => get_post_modified_time( 'c', false, $post ), |
| 1839 |
'post_title' => $post->post_title, |
| 1840 |
'post_type' => $post->post_type |
| 1841 |
]; |
| 1842 |
} |
| 1843 |
|
| 1844 |
return [ 'success' => true, 'data' => $urls ]; |
| 1845 |
|
| 1846 |
case 'mwseo_check_duplicate_titles': |
| 1847 |
$all_titles = []; |
| 1848 |
$duplicates = []; |
| 1849 |
|
| 1850 |
foreach ( $this->each_post( [ 'post_type' => ['post', 'page'], 'post_status' => 'publish' ] ) as $post ) { |
| 1851 |
$seo_title = get_post_meta( $post->ID, $this->core->meta_key_seo_title, true ); |
| 1852 |
if ( $seo_title ) { |
| 1853 |
if ( isset( $all_titles[$seo_title] ) ) { |
| 1854 |
if ( !isset( $duplicates[$seo_title] ) ) { |
| 1855 |
$duplicates[$seo_title] = [ $all_titles[$seo_title] ]; |
| 1856 |
} |
| 1857 |
$duplicates[$seo_title][] = [ |
| 1858 |
'post_id' => $post->ID, |
| 1859 |
'post_title' => $post->post_title, |
| 1860 |
'permalink' => get_permalink( $post->ID ) |
| 1861 |
]; |
| 1862 |
} else { |
| 1863 |
$all_titles[$seo_title] = [ |
| 1864 |
'post_id' => $post->ID, |
| 1865 |
'post_title' => $post->post_title, |
| 1866 |
'permalink' => get_permalink( $post->ID ) |
| 1867 |
]; |
| 1868 |
} |
| 1869 |
} |
| 1870 |
} |
| 1871 |
|
| 1872 |
return [ 'success' => true, 'data' => $duplicates ]; |
| 1873 |
|
| 1874 |
case 'mwseo_get_seo_statistics': |
| 1875 |
$stats = [ |
| 1876 |
'total_posts' => 0, |
| 1877 |
// Custom SEO counts (for backward compatibility) |
| 1878 |
'posts_with_seo_title' => 0, |
| 1879 |
'posts_with_seo_excerpt' => 0, |
| 1880 |
// Scoring stats |
| 1881 |
'posts_with_score' => 0, |
| 1882 |
'average_score' => 0, |
| 1883 |
'score_distribution' => [ |
| 1884 |
'A' => 0, |
| 1885 |
'B' => 0, |
| 1886 |
'C' => 0, |
| 1887 |
'D' => 0, |
| 1888 |
'F' => 0 |
| 1889 |
], |
| 1890 |
// NEW: Actual issue counts (these are what matter for prioritization) |
| 1891 |
'posts_with_title_issues' => 0, |
| 1892 |
'posts_with_description_issues' => 0, |
| 1893 |
'posts_needing_attention' => 0 |
| 1894 |
]; |
| 1895 |
|
| 1896 |
$total_score = 0; |
| 1897 |
$scored_posts = 0; |
| 1898 |
|
| 1899 |
foreach ( $this->each_post( [ 'post_type' => ['post', 'page'], 'post_status' => 'publish' ] ) as $post ) { |
| 1900 |
$stats['total_posts']++; |
| 1901 |
|
| 1902 |
$seo_eval = $this->evaluate_effective_seo( $post ); |
| 1903 |
if ( $seo_eval['has_custom_title'] ) { |
| 1904 |
$stats['posts_with_seo_title']++; |
| 1905 |
} |
| 1906 |
if ( $seo_eval['has_custom_description'] ) { |
| 1907 |
$stats['posts_with_seo_excerpt']++; |
| 1908 |
} |
| 1909 |
if ( !empty( $seo_eval['title_issues'] ) ) { |
| 1910 |
$stats['posts_with_title_issues']++; |
| 1911 |
} |
| 1912 |
if ( !empty( $seo_eval['description_issues'] ) ) { |
| 1913 |
$stats['posts_with_description_issues']++; |
| 1914 |
} |
| 1915 |
if ( $seo_eval['needs_attention'] ) { |
| 1916 |
$stats['posts_needing_attention']++; |
| 1917 |
} |
| 1918 |
|
| 1919 |
$score = get_post_meta( $post->ID, '_mwseo_score', true ); |
| 1920 |
if ( $score ) { |
| 1921 |
$stats['posts_with_score']++; |
| 1922 |
$total_score += (int) $score; |
| 1923 |
$scored_posts++; |
| 1924 |
|
| 1925 |
// Determine grade |
| 1926 |
if ( $score >= 90 ) $stats['score_distribution']['A']++; |
| 1927 |
elseif ( $score >= 80 ) $stats['score_distribution']['B']++; |
| 1928 |
elseif ( $score >= 70 ) $stats['score_distribution']['C']++; |
| 1929 |
elseif ( $score >= 60 ) $stats['score_distribution']['D']++; |
| 1930 |
else $stats['score_distribution']['F']++; |
| 1931 |
} |
| 1932 |
} |
| 1933 |
|
| 1934 |
if ( $scored_posts > 0 ) { |
| 1935 |
$stats['average_score'] = round( $total_score / $scored_posts, 1 ); |
| 1936 |
} |
| 1937 |
|
| 1938 |
// Rates (renamed from "coverage" for clarity - low rate is fine if auto-generated SEO is good) |
| 1939 |
$stats['custom_title_rate'] = $stats['total_posts'] > 0 ? round( ( $stats['posts_with_seo_title'] / $stats['total_posts'] ) * 100, 1 ) : 0; |
| 1940 |
$stats['custom_excerpt_rate'] = $stats['total_posts'] > 0 ? round( ( $stats['posts_with_seo_excerpt'] / $stats['total_posts'] ) * 100, 1 ) : 0; |
| 1941 |
// Keep old names for backward compatibility |
| 1942 |
$stats['seo_title_coverage'] = $stats['custom_title_rate']; |
| 1943 |
$stats['seo_excerpt_coverage'] = $stats['custom_excerpt_rate']; |
| 1944 |
|
| 1945 |
return [ 'success' => true, 'data' => $stats ]; |
| 1946 |
|
| 1947 |
// AI Keywords |
| 1948 |
case 'mwseo_get_ai_keywords': |
| 1949 |
$post = get_post( $args['post_id'] ); |
| 1950 |
if ( !$post ) { |
| 1951 |
return [ 'success' => false, 'error' => 'Post not found' ]; |
| 1952 |
} |
| 1953 |
|
| 1954 |
$keywords = get_post_meta( $post->ID, '_mwseo_keywords', true ); |
| 1955 |
return [ |
| 1956 |
'success' => true, |
| 1957 |
'data' => [ |
| 1958 |
'post_id' => $post->ID, |
| 1959 |
'keywords' => $keywords ?: [] |
| 1960 |
] |
| 1961 |
]; |
| 1962 |
|
| 1963 |
case 'mwseo_set_ai_keywords': |
| 1964 |
$post = get_post( $args['post_id'] ); |
| 1965 |
if ( !$post ) { |
| 1966 |
return [ 'success' => false, 'error' => 'Post not found' ]; |
| 1967 |
} |
| 1968 |
|
| 1969 |
$keywords = $args['keywords']; |
| 1970 |
if ( !is_array( $keywords ) ) { |
| 1971 |
return [ 'success' => false, 'error' => 'Keywords must be an array' ]; |
| 1972 |
} |
| 1973 |
|
| 1974 |
// Limit to 10 keywords max |
| 1975 |
$keywords = array_slice( $keywords, 0, 10 ); |
| 1976 |
|
| 1977 |
update_post_meta( $post->ID, '_mwseo_keywords', $keywords ); |
| 1978 |
return [ |
| 1979 |
'success' => true, |
| 1980 |
'data' => [ |
| 1981 |
'post_id' => $post->ID, |
| 1982 |
'keywords' => $keywords |
| 1983 |
], |
| 1984 |
'message' => 'AI keywords updated successfully' |
| 1985 |
]; |
| 1986 |
|
| 1987 |
// Bot Analytics - Advanced Tools |
| 1988 |
case 'mwseo_query_bot_traffic': |
| 1989 |
$query_args = array( |
| 1990 |
'start_date' => $args['start_date'] ?? null, |
| 1991 |
'end_date' => $args['end_date'] ?? null, |
| 1992 |
'post_id' => $args['post_id'] ?? null, |
| 1993 |
'bot_name' => $args['bot_name'] ?? null, |
| 1994 |
'bot_type' => $args['bot_type'] ?? null, |
| 1995 |
'group_by' => $args['group_by'] ?? null, |
| 1996 |
'metric' => $args['metric'] ?? 'visits' |
| 1997 |
); |
| 1998 |
|
| 1999 |
$result = $this->core->query_bot_traffic( $query_args ); |
| 2000 |
return [ 'success' => true, 'data' => $result ]; |
| 2001 |
|
| 2002 |
case 'mwseo_rank_posts_for_bots': |
| 2003 |
$rank_args = array( |
| 2004 |
'order' => $args['order'] ?? 'most', |
| 2005 |
'limit' => $args['limit'] ?? 20, |
| 2006 |
'min_visits' => $args['min_visits'] ?? 0, |
| 2007 |
'bot_name' => $args['bot_name'] ?? null, |
| 2008 |
'post_type' => $args['post_type'] ?? null, |
| 2009 |
'days' => $args['days'] ?? 30 |
| 2010 |
); |
| 2011 |
|
| 2012 |
$result = $this->core->rank_posts_for_bots( $rank_args ); |
| 2013 |
return [ 'success' => true, 'data' => $result ]; |
| 2014 |
|
| 2015 |
case 'mwseo_bot_profile': |
| 2016 |
if ( empty( $args['bot_name'] ) ) { |
| 2017 |
return [ 'success' => false, 'error' => 'bot_name is required' ]; |
| 2018 |
} |
| 2019 |
|
| 2020 |
$result = $this->core->get_bot_profile( |
| 2021 |
$args['bot_name'], |
| 2022 |
$args['start_date'] ?? null, |
| 2023 |
$args['end_date'] ?? null |
| 2024 |
); |
| 2025 |
|
| 2026 |
return [ 'success' => true, 'data' => $result ]; |
| 2027 |
|
| 2028 |
case 'mwseo_compare_bot_periods': |
| 2029 |
$compare_args = array( |
| 2030 |
'period1_start' => $args['period1_start'] ?? null, |
| 2031 |
'period1_end' => $args['period1_end'] ?? null, |
| 2032 |
'period2_start' => $args['period2_start'] ?? null, |
| 2033 |
'period2_end' => $args['period2_end'] ?? null, |
| 2034 |
'bot_name' => $args['bot_name'] ?? null, |
| 2035 |
'post_id' => $args['post_id'] ?? null |
| 2036 |
); |
| 2037 |
|
| 2038 |
$result = $this->core->compare_bot_periods( $compare_args ); |
| 2039 |
return [ 'success' => true, 'data' => $result ]; |
| 2040 |
|
| 2041 |
case 'mwseo_bot_mix': |
| 2042 |
$mix_args = array( |
| 2043 |
'start_date' => $args['start_date'] ?? null, |
| 2044 |
'end_date' => $args['end_date'] ?? null, |
| 2045 |
'post_type' => $args['post_type'] ?? null |
| 2046 |
); |
| 2047 |
|
| 2048 |
$result = $this->core->get_bot_mix( $mix_args ); |
| 2049 |
return [ 'success' => true, 'data' => $result ]; |
| 2050 |
|
| 2051 |
// Magic Fix / Issues |
| 2052 |
case 'mwseo_get_issues': |
| 2053 |
// Per-post mode |
| 2054 |
if ( !empty( $args['post_id'] ) ) { |
| 2055 |
$post = get_post( $args['post_id'] ); |
| 2056 |
if ( !$post ) { |
| 2057 |
return [ 'success' => false, 'error' => 'Post not found' ]; |
| 2058 |
} |
| 2059 |
|
| 2060 |
$analysis = get_post_meta( $post->ID, '_mwseo_analysis', true ); |
| 2061 |
$codes = get_post_meta( $post->ID, '_mwseo_codes', true ); |
| 2062 |
|
| 2063 |
if ( !$analysis || !isset( $analysis['tests'] ) ) { |
| 2064 |
return [ |
| 2065 |
'success' => false, |
| 2066 |
'error' => 'No analysis found for this post. Run mwseo_do_seo_scan first.' |
| 2067 |
]; |
| 2068 |
} |
| 2069 |
|
| 2070 |
$issues = []; |
| 2071 |
foreach ( $analysis['tests'] as $test_name => $score ) { |
| 2072 |
if ( $score === 'NA' ) continue; |
| 2073 |
if ( $score < 70 ) { |
| 2074 |
$issues[] = [ |
| 2075 |
'test' => $test_name, |
| 2076 |
'score' => $score, |
| 2077 |
'severity' => $score < 40 ? 'high' : 'medium' |
| 2078 |
]; |
| 2079 |
} |
| 2080 |
} |
| 2081 |
|
| 2082 |
return [ |
| 2083 |
'success' => true, |
| 2084 |
'data' => [ |
| 2085 |
'post_id' => $post->ID, |
| 2086 |
'overall_score' => $analysis['overall'] ?? 0, |
| 2087 |
'issues' => $issues, |
| 2088 |
'codes' => $codes ?: [] |
| 2089 |
] |
| 2090 |
]; |
| 2091 |
} |
| 2092 |
|
| 2093 |
// Site-wide aggregate mode — delegate to the shared aggregator so the |
| 2094 |
// MCP tool and the /aggregate_issues REST endpoint never drift apart. |
| 2095 |
global $mwseo_score; |
| 2096 |
if ( !$mwseo_score ) { |
| 2097 |
return [ 'success' => false, 'error' => 'Score module not initialized.' ]; |
| 2098 |
} |
| 2099 |
|
| 2100 |
$data = $mwseo_score->aggregate_issues( [ |
| 2101 |
'sample_size' => isset( $args['sample_size'] ) ? (int) $args['sample_size'] : 5000, |
| 2102 |
'post_type' => !empty( $args['post_type'] ) ? (array) $args['post_type'] : null, |
| 2103 |
] ); |
| 2104 |
|
| 2105 |
return [ 'success' => true, 'data' => $data ]; |
| 2106 |
|
| 2107 |
case 'mwseo_get_orphan_pages': |
| 2108 |
$post_type = !empty( $args['post_type'] ) ? (array) $args['post_type'] : [ 'post' ]; |
| 2109 |
$lang = isset( $args['lang'] ) ? (string) $args['lang'] : ''; |
| 2110 |
$created_after = isset( $args['created_after'] ) ? (string) $args['created_after'] : ''; |
| 2111 |
$min_word_count = isset( $args['min_word_count'] ) ? max( 0, (int) $args['min_word_count'] ) : 300; |
| 2112 |
$limit = isset( $args['limit'] ) ? max( 1, min( 500, (int) $args['limit'] ) ) : 50; |
| 2113 |
$scan_limit = 2000; |
| 2114 |
|
| 2115 |
$query_args = [ |
| 2116 |
'post_type' => $post_type, |
| 2117 |
'post_status' => 'publish', |
| 2118 |
'posts_per_page' => $scan_limit, |
| 2119 |
'orderby' => 'date', |
| 2120 |
'order' => 'DESC', |
| 2121 |
]; |
| 2122 |
if ( $created_after !== '' ) { |
| 2123 |
$query_args['date_query'] = [ [ 'after' => $created_after, 'inclusive' => true ] ]; |
| 2124 |
} |
| 2125 |
$query_args = $this->core->apply_language_filter( $query_args, $lang ); |
| 2126 |
|
| 2127 |
$candidates = get_posts( $query_args ); |
| 2128 |
$orphans = []; |
| 2129 |
$scanned = 0; |
| 2130 |
|
| 2131 |
global $wpdb; |
| 2132 |
foreach ( $candidates as $candidate ) { |
| 2133 |
$scanned++; |
| 2134 |
|
| 2135 |
$stripped = strip_tags( $candidate->post_content ); |
| 2136 |
$word_count = str_word_count( $stripped ); |
| 2137 |
if ( $word_count === 0 && mb_strlen( $stripped ) > 0 ) { |
| 2138 |
// CJK / non-Latin fallback: rough proxy via character count. |
| 2139 |
$word_count = (int) ( mb_strlen( $stripped ) / 2 ); |
| 2140 |
} |
| 2141 |
if ( $word_count < $min_word_count ) continue; |
| 2142 |
|
| 2143 |
// Approximate inbound count via slug substring match. Over-counts |
| 2144 |
// mentions vs. real links, which means we may miss some orphans |
| 2145 |
// (false negatives) — the safer error direction than false positives. |
| 2146 |
if ( empty( $candidate->post_name ) ) continue; |
| 2147 |
$like_slug = '%' . $wpdb->esc_like( '/' . $candidate->post_name . '/' ) . '%'; |
| 2148 |
$inbound = (int) $wpdb->get_var( $wpdb->prepare( |
| 2149 |
"SELECT COUNT(*) FROM {$wpdb->posts} |
| 2150 |
WHERE post_status = 'publish' AND ID != %d AND post_content LIKE %s", |
| 2151 |
$candidate->ID, $like_slug |
| 2152 |
) ); |
| 2153 |
|
| 2154 |
if ( $inbound > 0 ) continue; |
| 2155 |
|
| 2156 |
$orphans[] = [ |
| 2157 |
'post_id' => $candidate->ID, |
| 2158 |
'post_title' => $candidate->post_title, |
| 2159 |
'permalink' => get_permalink( $candidate ), |
| 2160 |
'post_type' => $candidate->post_type, |
| 2161 |
'post_date' => $candidate->post_date, |
| 2162 |
'word_count' => $word_count, |
| 2163 |
]; |
| 2164 |
} |
| 2165 |
|
| 2166 |
usort( $orphans, function( $a, $b ) { return $b['word_count'] - $a['word_count']; } ); |
| 2167 |
$orphans = array_slice( $orphans, 0, $limit ); |
| 2168 |
|
| 2169 |
return [ |
| 2170 |
'success' => true, |
| 2171 |
'data' => [ |
| 2172 |
'scanned' => $scanned, |
| 2173 |
'scan_limit' => $scan_limit, |
| 2174 |
'truncated' => $scanned >= $scan_limit, |
| 2175 |
'orphans' => $orphans, |
| 2176 |
] |
| 2177 |
]; |
| 2178 |
|
| 2179 |
case 'mwseo_suggest_internal_links': |
| 2180 |
if ( !$this->core->pro || !$this->core->pro->magic_fix ) { |
| 2181 |
return [ 'success' => false, 'error' => 'Magic Fix is not available (Pro required).' ]; |
| 2182 |
} |
| 2183 |
|
| 2184 |
// Resolve to a post-like object: either a real WP_Post or a pseudo-post for drafts. |
| 2185 |
$is_draft = false; |
| 2186 |
if ( !empty( $args['post_id'] ) ) { |
| 2187 |
$post = get_post( $args['post_id'] ); |
| 2188 |
if ( !$post ) { |
| 2189 |
return [ 'success' => false, 'error' => 'Post not found' ]; |
| 2190 |
} |
| 2191 |
} else if ( !empty( $args['draft_content'] ) && is_array( $args['draft_content'] ) ) { |
| 2192 |
$draft = $args['draft_content']; |
| 2193 |
if ( empty( $draft['title'] ) || empty( $draft['content'] ) ) { |
| 2194 |
return [ 'success' => false, 'error' => 'draft_content requires both title and content.' ]; |
| 2195 |
} |
| 2196 |
// Pseudo-post: ID=0 makes step2 skip category/tag lookups and fall through to |
| 2197 |
// pure keyword search, which is the right behavior for an unpublished draft. |
| 2198 |
$post = new stdClass(); |
| 2199 |
$post->ID = 0; |
| 2200 |
$post->post_title = (string) $draft['title']; |
| 2201 |
$post->post_content = (string) $draft['content']; |
| 2202 |
$post->post_excerpt = ''; |
| 2203 |
$post->post_type = !empty( $draft['post_type'] ) ? (string) $draft['post_type'] : 'post'; |
| 2204 |
$is_draft = true; |
| 2205 |
} else { |
| 2206 |
return [ 'success' => false, 'error' => 'Either post_id or draft_content is required.' ]; |
| 2207 |
} |
| 2208 |
|
| 2209 |
$max_candidates = isset( $args['max_candidates'] ) ? max( 1, min( 10, (int) $args['max_candidates'] ) ) : 10; |
| 2210 |
$magic = $this->core->pro->magic_fix; |
| 2211 |
|
| 2212 |
// Steps 1-3 only. Step 4 (placement generation) is sequential AI calls |
| 2213 |
// that easily blow past MCP timeouts; call mwseo_generate_internal_link_placements |
| 2214 |
// per chosen target instead. |
| 2215 |
$keywords = $magic->internal_links_step1( $post ); |
| 2216 |
$candidates = $magic->internal_links_step2( $post, $keywords ); |
| 2217 |
$selected_ids = $magic->internal_links_step3( $post, $candidates ); |
| 2218 |
|
| 2219 |
$suggestions = []; |
| 2220 |
$processed = 0; |
| 2221 |
foreach ( $selected_ids as $target_id ) { |
| 2222 |
if ( $processed >= $max_candidates ) break; |
| 2223 |
|
| 2224 |
$target_post = get_post( (int) $target_id ); |
| 2225 |
if ( !$target_post ) continue; |
| 2226 |
|
| 2227 |
$context = $magic->extract_candidate_context( $target_post, $keywords ); |
| 2228 |
|
| 2229 |
$suggestions[] = [ |
| 2230 |
'post_id' => $target_post->ID, |
| 2231 |
'post_title' => $target_post->post_title, |
| 2232 |
'post_url' => get_permalink( $target_post ), |
| 2233 |
'context_excerpt' => $context, |
| 2234 |
]; |
| 2235 |
$processed++; |
| 2236 |
} |
| 2237 |
|
| 2238 |
return [ |
| 2239 |
'success' => true, |
| 2240 |
'data' => [ |
| 2241 |
'mode' => $is_draft ? 'draft' : 'post', |
| 2242 |
'keywords' => $keywords, |
| 2243 |
'candidates_considered' => count( $candidates ), |
| 2244 |
'suggestions' => $suggestions, |
| 2245 |
'next_step' => 'For each promising candidate, call mwseo_generate_internal_link_placements with target_post_id=<candidate post_id> to get placement suggestions.' |
| 2246 |
] |
| 2247 |
]; |
| 2248 |
|
| 2249 |
case 'mwseo_suggest_seo_title': { |
| 2250 |
if ( empty( $args['post_id'] ) ) { |
| 2251 |
return [ 'success' => false, 'error' => 'post_id is required.' ]; |
| 2252 |
} |
| 2253 |
$post = get_post( (int) $args['post_id'] ); |
| 2254 |
if ( !$post ) { |
| 2255 |
return [ 'success' => false, 'error' => 'Post not found.' ]; |
| 2256 |
} |
| 2257 |
global $mwai; |
| 2258 |
if ( !$mwai ) { |
| 2259 |
return [ 'success' => false, 'error' => 'AI Engine is not available.' ]; |
| 2260 |
} |
| 2261 |
|
| 2262 |
$count = isset( $args['count'] ) ? max( 1, min( 10, (int) $args['count'] ) ) : 3; |
| 2263 |
$target_query = !empty( $args['target_query'] ) ? trim( (string) $args['target_query'] ) : ''; |
| 2264 |
$current_title = get_post_meta( $post->ID, $this->core->meta_key_seo_title, true ) ?: $post->post_title; |
| 2265 |
$language = $this->core->get_post_language_name( $post->ID ); |
| 2266 |
$excerpt = wp_trim_words( strip_tags( $post->post_content ), 60 ); |
| 2267 |
|
| 2268 |
$query_line = $target_query !== '' |
| 2269 |
? sprintf( 'The page should rank for: "%s". Include this phrasing naturally where it fits.', $target_query ) |
| 2270 |
: ''; |
| 2271 |
|
| 2272 |
$prompt = sprintf( |
| 2273 |
"Write %d distinct, compelling SEO title candidates for the post below. Titles must:\n" . |
| 2274 |
"- Be 30 to 70 characters\n" . |
| 2275 |
"- Be specific and click-worthy (no clickbait, no all-caps)\n" . |
| 2276 |
"- Avoid emoji and trailing punctuation\n" . |
| 2277 |
"%s\n" . |
| 2278 |
"\nCurrent title: %s\nPost content sample: %s\n\nReturn ONLY a JSON array of strings, nothing else.", |
| 2279 |
$count, $query_line, $current_title, $excerpt |
| 2280 |
); |
| 2281 |
$prompt = sprintf( '<instructions>Reply ONLY with a JSON array of %d title strings in %s. No other text, no markdown fences.</instructions> <prompt>%s</prompt>', $count, $language, $prompt ); |
| 2282 |
|
| 2283 |
$raw = $mwai->simpleTextQuery( $prompt, [ 'scope' => 'seo' ] ); |
| 2284 |
$raw = trim( (string) $raw ); |
| 2285 |
$raw = preg_replace( '/^```(json)?\s*/m', '', $raw ); |
| 2286 |
$raw = preg_replace( '/```\s*$/m', '', $raw ); |
| 2287 |
$candidates = json_decode( trim( $raw ), true ); |
| 2288 |
if ( !is_array( $candidates ) ) { |
| 2289 |
return [ 'success' => false, 'error' => 'AI returned an unparseable response.', '_raw' => substr( $raw, 0, 200 ) ]; |
| 2290 |
} |
| 2291 |
$candidates = array_values( array_filter( array_map( 'trim', array_map( 'strval', $candidates ) ) ) ); |
| 2292 |
$candidates = array_slice( $candidates, 0, $count ); |
| 2293 |
|
| 2294 |
return [ |
| 2295 |
'success' => true, |
| 2296 |
'data' => [ |
| 2297 |
'post_id' => $post->ID, |
| 2298 |
'current_title' => $current_title, |
| 2299 |
'candidates' => $candidates, |
| 2300 |
'next_step' => sprintf( 'Pick a candidate and apply it with mwseo_set_seo_title post_id=%d title="<chosen>".', $post->ID ) |
| 2301 |
] |
| 2302 |
]; |
| 2303 |
} |
| 2304 |
|
| 2305 |
case 'mwseo_suggest_seo_excerpt': { |
| 2306 |
if ( empty( $args['post_id'] ) ) { |
| 2307 |
return [ 'success' => false, 'error' => 'post_id is required.' ]; |
| 2308 |
} |
| 2309 |
$post = get_post( (int) $args['post_id'] ); |
| 2310 |
if ( !$post ) { |
| 2311 |
return [ 'success' => false, 'error' => 'Post not found.' ]; |
| 2312 |
} |
| 2313 |
global $mwai; |
| 2314 |
if ( !$mwai ) { |
| 2315 |
return [ 'success' => false, 'error' => 'AI Engine is not available.' ]; |
| 2316 |
} |
| 2317 |
|
| 2318 |
$count = isset( $args['count'] ) ? max( 1, min( 10, (int) $args['count'] ) ) : 3; |
| 2319 |
$target_query = !empty( $args['target_query'] ) ? trim( (string) $args['target_query'] ) : ''; |
| 2320 |
$current_excerpt = get_post_meta( $post->ID, $this->core->meta_key_seo_excerpt, true ) ?: $post->post_excerpt; |
| 2321 |
$language = $this->core->get_post_language_name( $post->ID ); |
| 2322 |
$content_sample = wp_trim_words( strip_tags( $post->post_content ), 120 ); |
| 2323 |
|
| 2324 |
$query_line = $target_query !== '' |
| 2325 |
? sprintf( 'The page should rank for: "%s". Include this phrasing naturally where it fits.', $target_query ) |
| 2326 |
: ''; |
| 2327 |
|
| 2328 |
$prompt = sprintf( |
| 2329 |
"Write %d distinct meta description candidates for the post below. Each must:\n" . |
| 2330 |
"- Be 120 to 155 characters\n" . |
| 2331 |
"- Summarize the page's actual value and invite the click (no clickbait, no all-caps)\n" . |
| 2332 |
"- Avoid emoji and quotation marks\n" . |
| 2333 |
"%s\n" . |
| 2334 |
"\nPost title: %s\nCurrent meta description: %s\nPost content sample: %s\n\nReturn ONLY a JSON array of strings, nothing else.", |
| 2335 |
$count, $query_line, $post->post_title, ( $current_excerpt ?: '(none)' ), $content_sample |
| 2336 |
); |
| 2337 |
$prompt = sprintf( '<instructions>Reply ONLY with a JSON array of %d meta description strings in %s. No other text, no markdown fences.</instructions> <prompt>%s</prompt>', $count, $language, $prompt ); |
| 2338 |
|
| 2339 |
$raw = $mwai->simpleTextQuery( $prompt, [ 'scope' => 'seo' ] ); |
| 2340 |
$raw = trim( (string) $raw ); |
| 2341 |
$raw = preg_replace( '/^```(json)?\s*/m', '', $raw ); |
| 2342 |
$raw = preg_replace( '/```\s*$/m', '', $raw ); |
| 2343 |
$candidates = json_decode( trim( $raw ), true ); |
| 2344 |
if ( !is_array( $candidates ) ) { |
| 2345 |
return [ 'success' => false, 'error' => 'AI returned an unparseable response.', '_raw' => substr( $raw, 0, 200 ) ]; |
| 2346 |
} |
| 2347 |
$candidates = array_values( array_filter( array_map( 'trim', array_map( 'strval', $candidates ) ) ) ); |
| 2348 |
$candidates = array_slice( $candidates, 0, $count ); |
| 2349 |
|
| 2350 |
return [ |
| 2351 |
'success' => true, |
| 2352 |
'data' => [ |
| 2353 |
'post_id' => $post->ID, |
| 2354 |
'current_excerpt' => $current_excerpt, |
| 2355 |
'candidates' => $candidates, |
| 2356 |
'next_step' => sprintf( 'Pick a candidate and apply it with mwseo_set_seo_excerpt post_id=%d excerpt="<chosen>".', $post->ID ) |
| 2357 |
] |
| 2358 |
]; |
| 2359 |
} |
| 2360 |
|
| 2361 |
case 'mwseo_generate_internal_link_placements': |
| 2362 |
if ( !$this->core->pro || !$this->core->pro->magic_fix ) { |
| 2363 |
return [ 'success' => false, 'error' => 'Magic Fix is not available (Pro required).' ]; |
| 2364 |
} |
| 2365 |
if ( empty( $args['target_post_id'] ) ) { |
| 2366 |
return [ 'success' => false, 'error' => 'target_post_id is required.' ]; |
| 2367 |
} |
| 2368 |
$target_post = get_post( (int) $args['target_post_id'] ); |
| 2369 |
if ( !$target_post ) { |
| 2370 |
return [ 'success' => false, 'error' => 'Target post not found.' ]; |
| 2371 |
} |
| 2372 |
|
| 2373 |
// Resolve source: real post or draft pseudo-post |
| 2374 |
if ( !empty( $args['post_id'] ) ) { |
| 2375 |
$source = get_post( $args['post_id'] ); |
| 2376 |
if ( !$source ) { |
| 2377 |
return [ 'success' => false, 'error' => 'Source post not found.' ]; |
| 2378 |
} |
| 2379 |
} else if ( !empty( $args['draft_content'] ) && is_array( $args['draft_content'] ) ) { |
| 2380 |
$draft = $args['draft_content']; |
| 2381 |
if ( empty( $draft['title'] ) || empty( $draft['content'] ) ) { |
| 2382 |
return [ 'success' => false, 'error' => 'draft_content requires both title and content.' ]; |
| 2383 |
} |
| 2384 |
$source = new stdClass(); |
| 2385 |
$source->ID = 0; |
| 2386 |
$source->post_title = (string) $draft['title']; |
| 2387 |
$source->post_content = (string) $draft['content']; |
| 2388 |
$source->post_excerpt = ''; |
| 2389 |
$source->post_type = !empty( $draft['post_type'] ) ? (string) $draft['post_type'] : 'post'; |
| 2390 |
} else { |
| 2391 |
return [ 'success' => false, 'error' => 'Either post_id or draft_content is required.' ]; |
| 2392 |
} |
| 2393 |
|
| 2394 |
$result = $this->core->pro->magic_fix->internal_links_step4( $source, $target_post ); |
| 2395 |
return [ |
| 2396 |
'success' => true, |
| 2397 |
'data' => is_array( $result ) ? $result : [ |
| 2398 |
'post_id' => $target_post->ID, |
| 2399 |
'post_title' => $target_post->post_title, |
| 2400 |
'post_url' => get_permalink( $target_post ), |
| 2401 |
'options' => [], |
| 2402 |
'note' => 'No natural placement found.' |
| 2403 |
] |
| 2404 |
]; |
| 2405 |
|
| 2406 |
// Google Search Console (Pro) |
| 2407 |
case 'mwseo_gsc_status': { |
| 2408 |
if ( !$this->core->pro || !$this->core->pro->search_console ) { |
| 2409 |
return [ 'success' => false, 'error' => 'Search Console module is not available (Pro required).' ]; |
| 2410 |
} |
| 2411 |
$gsc = $this->core->pro->search_console; |
| 2412 |
$client_id = $this->core->get_option( 'google_analytics_client_id', '' ); |
| 2413 |
$client_secret = $this->core->get_option( 'google_analytics_client_secret', '' ); |
| 2414 |
|
| 2415 |
if ( empty( $client_id ) || empty( $client_secret ) ) { |
| 2416 |
return [ |
| 2417 |
'success' => true, |
| 2418 |
'connected' => false, |
| 2419 |
'summary' => '⚠️ Google OAuth credentials are not configured. Set google_analytics_client_id and google_analytics_client_secret in Settings. Those credentials are shared with Google Search Console.', |
| 2420 |
'next_step' => 'Open Settings → Analytics → set Client ID and Client Secret from your Google Cloud Console OAuth client.' |
| 2421 |
]; |
| 2422 |
} |
| 2423 |
|
| 2424 |
if ( !$gsc->is_authenticated() ) { |
| 2425 |
return [ |
| 2426 |
'success' => true, |
| 2427 |
'connected' => false, |
| 2428 |
'summary' => '⚠️ Not yet connected to Google Search Console. Open the authorization URL below in a browser, sign in, and authorize.', |
| 2429 |
'auth_url' => $gsc->get_auth_url(), |
| 2430 |
'next_step' => 'Open auth_url in a browser, authorize, then call mwseo_gsc_status again.' |
| 2431 |
]; |
| 2432 |
} |
| 2433 |
|
| 2434 |
$properties = $gsc->list_properties(); |
| 2435 |
$current_property = $gsc->get_property(); |
| 2436 |
|
| 2437 |
if ( empty( $current_property ) ) { |
| 2438 |
$suggestion = !empty( $properties ) ? $properties[0]['site_url'] : null; |
| 2439 |
$summary = $suggestion |
| 2440 |
? sprintf( '🟡 Connected, but no property selected yet. Suggested: %s. Call mwseo_gsc_set_property to choose.', $suggestion ) |
| 2441 |
: '🟡 Connected, but no verified properties were returned by Google. Make sure your Google account owns at least one verified Search Console property.'; |
| 2442 |
return [ |
| 2443 |
'success' => true, |
| 2444 |
'connected' => true, |
| 2445 |
'property' => null, |
| 2446 |
'available_properties' => $properties ?: [], |
| 2447 |
'summary' => $summary, |
| 2448 |
'next_step' => $suggestion ? "Call mwseo_gsc_set_property with property=\"$suggestion\"." : null |
| 2449 |
]; |
| 2450 |
} |
| 2451 |
|
| 2452 |
return [ |
| 2453 |
'success' => true, |
| 2454 |
'connected' => true, |
| 2455 |
'property' => $current_property, |
| 2456 |
'available_properties' => $properties ?: [], |
| 2457 |
'summary' => sprintf( '� |
| 2458 |
Connected to %s. Run mwseo_gsc_quick_wins for ranked opportunities, mwseo_gsc_top_pages or mwseo_gsc_top_queries for exploration, or mwseo_gsc_post_pulse on any post_id.', $current_property ) |
| 2459 |
]; |
| 2460 |
} |
| 2461 |
|
| 2462 |
case 'mwseo_gsc_set_property': { |
| 2463 |
if ( !$this->core->pro || !$this->core->pro->search_console ) { |
| 2464 |
return [ 'success' => false, 'error' => 'Search Console module is not available (Pro required).' ]; |
| 2465 |
} |
| 2466 |
if ( empty( $args['property'] ) ) { |
| 2467 |
return [ 'success' => false, 'error' => 'property is required.' ]; |
| 2468 |
} |
| 2469 |
$this->core->pro->search_console->set_property( (string) $args['property'] ); |
| 2470 |
return [ |
| 2471 |
'success' => true, |
| 2472 |
'property' => (string) $args['property'], |
| 2473 |
'summary' => sprintf( '� |
| 2474 |
Active property set to %s.', $args['property'] ) |
| 2475 |
]; |
| 2476 |
} |
| 2477 |
|
| 2478 |
case 'mwseo_gsc_quick_wins': { |
| 2479 |
if ( !$this->core->pro || !$this->core->pro->search_console ) { |
| 2480 |
return [ 'success' => false, 'error' => 'Search Console module is not available (Pro required).' ]; |
| 2481 |
} |
| 2482 |
$gsc = $this->core->pro->search_console; |
| 2483 |
if ( !$gsc->is_authenticated() || ( !$gsc->get_property() && empty( $args['property'] ) ) ) { |
| 2484 |
return [ 'success' => false, 'error' => 'Not connected to Google Search Console, or no property set. Run mwseo_gsc_status first, or pass a property argument.' ]; |
| 2485 |
} |
| 2486 |
return $gsc->get_quick_wins( $args ); |
| 2487 |
} |
| 2488 |
|
| 2489 |
case 'mwseo_gsc_ai_overview_suspects': { |
| 2490 |
if ( !$this->core->pro || !$this->core->pro->search_console ) { |
| 2491 |
return [ 'success' => false, 'error' => 'Search Console module is not available (Pro required).' ]; |
| 2492 |
} |
| 2493 |
$gsc = $this->core->pro->search_console; |
| 2494 |
if ( !$gsc->is_authenticated() || ( !$gsc->get_property() && empty( $args['property'] ) ) ) { |
| 2495 |
return [ 'success' => false, 'error' => 'Not connected to Google Search Console, or no property set. Run mwseo_gsc_status first, or pass a property argument.' ]; |
| 2496 |
} |
| 2497 |
return $gsc->get_ai_overview_suspects( $args ); |
| 2498 |
} |
| 2499 |
|
| 2500 |
case 'mwseo_gsc_site_pulse': { |
| 2501 |
if ( !$this->core->pro || !$this->core->pro->search_console ) { |
| 2502 |
return [ 'success' => false, 'error' => 'Search Console module is not available (Pro required).' ]; |
| 2503 |
} |
| 2504 |
$gsc = $this->core->pro->search_console; |
| 2505 |
if ( !$gsc->is_authenticated() || ( !$gsc->get_property() && empty( $args['property'] ) ) ) { |
| 2506 |
return [ 'success' => false, 'error' => 'Not connected to Google Search Console, or no property set. Run mwseo_gsc_status first, or pass a property argument.' ]; |
| 2507 |
} |
| 2508 |
return [ 'success' => true, 'data' => $gsc->get_summary( $args ) ]; |
| 2509 |
} |
| 2510 |
|
| 2511 |
case 'mwseo_gsc_weekly_digest': { |
| 2512 |
if ( !$this->core->pro || !$this->core->pro->search_console ) { |
| 2513 |
return [ 'success' => false, 'error' => 'Search Console module is not available (Pro required).' ]; |
| 2514 |
} |
| 2515 |
$gsc = $this->core->pro->search_console; |
| 2516 |
if ( !$gsc->is_authenticated() || ( !$gsc->get_property() && empty( $args['property'] ) ) ) { |
| 2517 |
return [ 'success' => false, 'error' => 'Not connected to Google Search Console, or no property set. Run mwseo_gsc_status first, or pass a property argument.' ]; |
| 2518 |
} |
| 2519 |
return $gsc->get_weekly_digest( $args ); |
| 2520 |
} |
| 2521 |
|
| 2522 |
case 'mwseo_gsc_post_pulse': { |
| 2523 |
if ( !$this->core->pro || !$this->core->pro->search_console ) { |
| 2524 |
return [ 'success' => false, 'error' => 'Search Console module is not available (Pro required).' ]; |
| 2525 |
} |
| 2526 |
if ( empty( $args['post_id'] ) ) { |
| 2527 |
return [ 'success' => false, 'error' => 'post_id is required.' ]; |
| 2528 |
} |
| 2529 |
$gsc = $this->core->pro->search_console; |
| 2530 |
if ( !$gsc->is_authenticated() || ( !$gsc->get_property() && empty( $args['property'] ) ) ) { |
| 2531 |
return [ 'success' => false, 'error' => 'Not connected to Google Search Console, or no property set. Run mwseo_gsc_status first, or pass a property argument.' ]; |
| 2532 |
} |
| 2533 |
$days = isset( $args['days'] ) ? max( 7, min( 90, (int) $args['days'] ) ) : 28; |
| 2534 |
$property = !empty( $args['property'] ) ? (string) $args['property'] : null; |
| 2535 |
$debug = !empty( $args['debug'] ); |
| 2536 |
$result = $gsc->get_post_pulse( (int) $args['post_id'], $days, $property, $debug ); |
| 2537 |
if ( $result === false ) { |
| 2538 |
return [ 'success' => false, 'error' => $gsc->get_last_error() ?: 'Unknown error' ]; |
| 2539 |
} |
| 2540 |
return [ 'success' => true, 'data' => $result ]; |
| 2541 |
} |
| 2542 |
|
| 2543 |
case 'mwseo_gsc_top_queries': { |
| 2544 |
if ( !$this->core->pro || !$this->core->pro->search_console ) { |
| 2545 |
return [ 'success' => false, 'error' => 'Search Console module is not available (Pro required).' ]; |
| 2546 |
} |
| 2547 |
$gsc = $this->core->pro->search_console; |
| 2548 |
if ( !$gsc->is_authenticated() || ( !$gsc->get_property() && empty( $args['property'] ) ) ) { |
| 2549 |
return [ 'success' => false, 'error' => 'Not connected to Google Search Console, or no property set. Run mwseo_gsc_status first, or pass a property argument.' ]; |
| 2550 |
} |
| 2551 |
return [ 'success' => true, 'data' => $gsc->get_top_queries( $args ) ]; |
| 2552 |
} |
| 2553 |
|
| 2554 |
case 'mwseo_gsc_top_pages': { |
| 2555 |
if ( !$this->core->pro || !$this->core->pro->search_console ) { |
| 2556 |
return [ 'success' => false, 'error' => 'Search Console module is not available (Pro required).' ]; |
| 2557 |
} |
| 2558 |
$gsc = $this->core->pro->search_console; |
| 2559 |
if ( !$gsc->is_authenticated() || ( !$gsc->get_property() && empty( $args['property'] ) ) ) { |
| 2560 |
return [ 'success' => false, 'error' => 'Not connected to Google Search Console, or no property set. Run mwseo_gsc_status first, or pass a property argument.' ]; |
| 2561 |
} |
| 2562 |
return [ 'success' => true, 'data' => $gsc->get_top_pages( $args ) ]; |
| 2563 |
} |
| 2564 |
|
| 2565 |
// AI Visibility |
| 2566 |
case 'mwseo_ai_visibility_brands': { |
| 2567 |
$mod = $this->core->ai_visibility(); |
| 2568 |
if ( !$mod || !$mod->is_enabled() ) { |
| 2569 |
return [ 'success' => false, 'error' => 'AI Visibility is not enabled.' ]; |
| 2570 |
} |
| 2571 |
$brands = array(); |
| 2572 |
foreach ( $mod->list_brands() as $b ) { |
| 2573 |
$m = isset( $b['metrics'] ) ? $b['metrics'] : array(); |
| 2574 |
$providers = array(); |
| 2575 |
foreach ( (array) ( isset( $b['by_provider'] ) ? $b['by_provider'] : array() ) as $p ) { |
| 2576 |
$providers[ $p['provider'] ] = $p['mentions'] > 0 |
| 2577 |
? ( $p['rank'] !== null ? 'rank #' . $p['rank'] : 'mentioned' ) |
| 2578 |
: 'not mentioned'; |
| 2579 |
} |
| 2580 |
$brands[] = array( |
| 2581 |
'brand_id' => $b['id'], |
| 2582 |
'name' => $b['name'], |
| 2583 |
'url' => $b['url'], |
| 2584 |
'score' => $b['score'], |
| 2585 |
'providers' => $providers, |
| 2586 |
'mentions' => isset( $m['mentions'] ) ? $m['mentions'] : 0, |
| 2587 |
'citations' => isset( $m['citations'] ) ? $m['citations'] : 0, |
| 2588 |
'checks' => isset( $m['total'] ) ? $m['total'] : 0, |
| 2589 |
'sentiment' => isset( $m['sentiment'] ) ? $m['sentiment'] : null, |
| 2590 |
'last_scan_at' => $b['last_scan_at'], |
| 2591 |
); |
| 2592 |
} |
| 2593 |
return [ 'success' => true, 'data' => array( |
| 2594 |
'brands' => $brands, |
| 2595 |
'note' => 'Scores measure what AI models answer from memory (no live web browsing). 0 means the brand was never mentioned. "mentions" is how many checks named the brand, "citations" how many also linked to it: a mention without a citation rarely turns into a click.', |
| 2596 |
) ]; |
| 2597 |
} |
| 2598 |
|
| 2599 |
case 'mwseo_ai_visibility_detail': { |
| 2600 |
$mod = $this->core->ai_visibility(); |
| 2601 |
if ( !$mod || !$mod->is_enabled() ) { |
| 2602 |
return [ 'success' => false, 'error' => 'AI Visibility is not enabled.' ]; |
| 2603 |
} |
| 2604 |
$brand_id = isset( $args['brand_id'] ) ? intval( $args['brand_id'] ) : 0; |
| 2605 |
$brand = $mod->get_brand( $brand_id ); |
| 2606 |
if ( !$brand ) { |
| 2607 |
return [ 'success' => false, 'error' => 'Brand not found.' ]; |
| 2608 |
} |
| 2609 |
return [ 'success' => true, 'data' => array( |
| 2610 |
'brand' => array( |
| 2611 |
'brand_id' => $brand['id'], |
| 2612 |
'name' => $brand['name'], |
| 2613 |
'url' => $brand['url'], |
| 2614 |
'score' => $brand['score'], |
| 2615 |
), |
| 2616 |
'queries' => $mod->get_brand_queries( $brand_id ), |
| 2617 |
'competitors' => $mod->get_brand_competitors( $brand_id ), |
| 2618 |
'timeseries' => $mod->get_brand_timeseries( $brand_id ), |
| 2619 |
) ]; |
| 2620 |
} |
| 2621 |
|
| 2622 |
case 'mwseo_ai_visibility_suggest_questions': { |
| 2623 |
$mod = $this->core->ai_visibility(); |
| 2624 |
if ( !$mod || !$mod->is_enabled() ) { |
| 2625 |
return [ 'success' => false, 'error' => 'AI Visibility is not enabled.' ]; |
| 2626 |
} |
| 2627 |
$questions = $mod->generate_queries( array( |
| 2628 |
'name' => isset( $args['name'] ) ? (string) $args['name'] : '', |
| 2629 |
'description' => isset( $args['description'] ) ? (string) $args['description'] : '', |
| 2630 |
'competitors' => isset( $args['competitors'] ) ? $args['competitors'] : array(), |
| 2631 |
) ); |
| 2632 |
if ( is_wp_error( $questions ) ) { |
| 2633 |
return [ 'success' => false, 'error' => $questions->get_error_message() ]; |
| 2634 |
} |
| 2635 |
return [ 'success' => true, 'data' => array( |
| 2636 |
'questions' => $questions, |
| 2637 |
'note' => 'Nothing was created. Pass an edited list as the "questions" argument of mwseo_ai_visibility_add_brand.', |
| 2638 |
) ]; |
| 2639 |
} |
| 2640 |
|
| 2641 |
case 'mwseo_ai_visibility_add_brand': { |
| 2642 |
$mod = $this->core->ai_visibility(); |
| 2643 |
if ( !$mod || !$mod->is_enabled() ) { |
| 2644 |
return [ 'success' => false, 'error' => 'AI Visibility is not enabled.' ]; |
| 2645 |
} |
| 2646 |
$name = isset( $args['name'] ) ? trim( (string) $args['name'] ) : ''; |
| 2647 |
if ( $name === '' ) { |
| 2648 |
return [ 'success' => false, 'error' => 'A brand or product name is required.' ]; |
| 2649 |
} |
| 2650 |
$questions = ( isset( $args['questions'] ) && is_array( $args['questions'] ) ) ? $args['questions'] : array(); |
| 2651 |
$generated = false; |
| 2652 |
if ( empty( $questions ) ) { |
| 2653 |
$questions = $mod->generate_queries( array( |
| 2654 |
'name' => $name, |
| 2655 |
'description' => isset( $args['description'] ) ? (string) $args['description'] : '', |
| 2656 |
'competitors' => isset( $args['competitors'] ) ? $args['competitors'] : array(), |
| 2657 |
) ); |
| 2658 |
if ( is_wp_error( $questions ) ) { |
| 2659 |
return [ 'success' => false, 'error' => 'Could not generate questions: ' . $questions->get_error_message() ]; |
| 2660 |
} |
| 2661 |
$generated = true; |
| 2662 |
} |
| 2663 |
$brand = $mod->save_brand( array( |
| 2664 |
'name' => $name, |
| 2665 |
'url' => isset( $args['url'] ) ? (string) $args['url'] : '', |
| 2666 |
'description' => isset( $args['description'] ) ? (string) $args['description'] : '', |
| 2667 |
'competitors' => isset( $args['competitors'] ) ? $args['competitors'] : array(), |
| 2668 |
'queries' => $questions, |
| 2669 |
'enabled' => 1, |
| 2670 |
) ); |
| 2671 |
if ( is_wp_error( $brand ) ) { |
| 2672 |
return [ 'success' => false, 'error' => $brand->get_error_message() ]; |
| 2673 |
} |
| 2674 |
return [ 'success' => true, 'data' => array( |
| 2675 |
'brand_id' => $brand['id'], |
| 2676 |
'name' => $brand['name'], |
| 2677 |
'url' => $brand['url'], |
| 2678 |
'questions' => $brand['queries'], |
| 2679 |
'questions_generated' => $generated, |
| 2680 |
'next' => 'The brand has no data yet. Run mwseo_ai_visibility_scan with this brand_id to score it.', |
| 2681 |
) ]; |
| 2682 |
} |
| 2683 |
|
| 2684 |
case 'mwseo_ai_visibility_scan': { |
| 2685 |
$mod = $this->core->ai_visibility(); |
| 2686 |
if ( !$mod || !$mod->is_enabled() ) { |
| 2687 |
return [ 'success' => false, 'error' => 'AI Visibility is not enabled.' ]; |
| 2688 |
} |
| 2689 |
$brand_id = isset( $args['brand_id'] ) ? intval( $args['brand_id'] ) : 0; |
| 2690 |
$plan = $mod->build_scan_plan( $brand_id ); |
| 2691 |
if ( is_wp_error( $plan ) ) { |
| 2692 |
return [ 'success' => false, 'error' => $plan->get_error_message() ]; |
| 2693 |
} |
| 2694 |
$brand = $mod->get_brand( $brand_id ); |
| 2695 |
$total = (int) $plan['total']; |
| 2696 |
$batch = isset( $args['batch'] ) ? preg_replace( '/[^a-zA-Z0-9_]/', '', (string) $args['batch'] ) : ''; |
| 2697 |
|
| 2698 |
// The dry run is the guard: a scan is one paid AI call per unit, so the |
| 2699 |
// first call never spends anything. Resuming a batch is already confirmed. |
| 2700 |
if ( $batch === '' && empty( $args['confirm'] ) ) { |
| 2701 |
$surfaces = array(); |
| 2702 |
foreach ( $plan['units'] as $u ) { |
| 2703 |
$label = $u['surface']['provider'] . ' / ' . $u['surface']['label']; |
| 2704 |
$surfaces[ $label ] = true; |
| 2705 |
} |
| 2706 |
$estimate = $mod->estimate_scan_cost( $total ); |
| 2707 |
return [ 'success' => true, 'data' => array( |
| 2708 |
'dry_run' => true, |
| 2709 |
'brand' => $brand['name'], |
| 2710 |
'questions' => count( $brand['queries'] ), |
| 2711 |
'surfaces' => array_keys( $surfaces ), |
| 2712 |
'units' => $total, |
| 2713 |
'estimated_cost_usd' => $estimate, |
| 2714 |
'cost_basis' => $estimate === null |
| 2715 |
? 'Unknown: no provider on this site has ever reported a price, so the only honest figure is the number of paid calls above.' |
| 2716 |
: 'Average cost of the units already scanned on this site.', |
| 2717 |
'note' => 'Nothing was scanned. Each unit is one paid AI call. Call again with confirm set to true to start, then keep calling with the returned batch and next_offset until remaining is 0.', |
| 2718 |
) ]; |
| 2719 |
} |
| 2720 |
|
| 2721 |
if ( $batch === '' ) { $batch = $plan['batch']; } |
| 2722 |
$offset = isset( $args['offset'] ) ? max( 0, intval( $args['offset'] ) ) : 0; |
| 2723 |
$max = isset( $args['max_units'] ) ? intval( $args['max_units'] ) : 6; |
| 2724 |
$max = max( 1, min( 20, $max ) ); |
| 2725 |
$slice = array_slice( $plan['units'], $offset, $max ); |
| 2726 |
|
| 2727 |
$results = array(); |
| 2728 |
$cost = 0; |
| 2729 |
foreach ( $slice as $unit ) { |
| 2730 |
$one = $mod->scan_one( $brand_id, $unit['query'], $unit['surface'], $batch ); |
| 2731 |
if ( is_wp_error( $one ) ) { |
| 2732 |
// Keep what already ran: the batch is resumable from where it stopped. |
| 2733 |
return [ 'success' => false, 'error' => $one->get_error_message() |
| 2734 |
. ' Scan stopped after ' . count( $results ) . ' unit(s). Resume with batch "' . $batch |
| 2735 |
. '" and offset ' . ( $offset + count( $results ) ) . '.' ]; |
| 2736 |
} |
| 2737 |
if ( $one['cost'] !== null ) { $cost += (float) $one['cost']; } |
| 2738 |
$results[] = array( |
| 2739 |
'question' => $one['query'], |
| 2740 |
'provider' => $one['provider'], |
| 2741 |
'mentioned' => $one['mentioned'], |
| 2742 |
'rank' => $one['rank'], |
| 2743 |
'cited' => $one['cited'], |
| 2744 |
); |
| 2745 |
} |
| 2746 |
|
| 2747 |
$offset += count( $slice ); |
| 2748 |
$remaining = max( 0, $total - $offset ); |
| 2749 |
$data = array( |
| 2750 |
'batch' => $batch, |
| 2751 |
'scanned' => $offset, |
| 2752 |
'total' => $total, |
| 2753 |
'remaining' => $remaining, |
| 2754 |
'cost_usd' => round( $cost, 5 ), |
| 2755 |
'results' => $results, |
| 2756 |
); |
| 2757 |
if ( $remaining > 0 ) { |
| 2758 |
$data['next_offset'] = $offset; |
| 2759 |
$data['next'] = 'Call mwseo_ai_visibility_scan again with the same brand_id, batch "' . $batch . '", offset ' . $offset . ' and confirm true.'; |
| 2760 |
} |
| 2761 |
else { |
| 2762 |
$brand = $mod->finalize_scan( $brand_id ); |
| 2763 |
$data['done'] = true; |
| 2764 |
$data['score'] = $brand['score']; |
| 2765 |
$data['next'] = 'Scan complete. Use mwseo_ai_visibility_detail for the per-question breakdown.'; |
| 2766 |
} |
| 2767 |
return [ 'success' => true, 'data' => $data ]; |
| 2768 |
} |
| 2769 |
|
| 2770 |
case 'mwseo_ai_visibility_delete_brand': { |
| 2771 |
$mod = $this->core->ai_visibility(); |
| 2772 |
if ( !$mod || !$mod->is_enabled() ) { |
| 2773 |
return [ 'success' => false, 'error' => 'AI Visibility is not enabled.' ]; |
| 2774 |
} |
| 2775 |
$brand_id = isset( $args['brand_id'] ) ? intval( $args['brand_id'] ) : 0; |
| 2776 |
$brand = $mod->get_brand( $brand_id ); |
| 2777 |
if ( !$brand ) { |
| 2778 |
return [ 'success' => false, 'error' => 'Brand not found.' ]; |
| 2779 |
} |
| 2780 |
if ( empty( $args['confirm'] ) ) { |
| 2781 |
return [ 'success' => true, 'data' => array( |
| 2782 |
'deleted' => false, |
| 2783 |
'brand_id' => $brand['id'], |
| 2784 |
'name' => $brand['name'], |
| 2785 |
'score' => $brand['score'], |
| 2786 |
'note' => 'Nothing was deleted. This would permanently remove the brand and its whole scan history. Call again with confirm set to true.', |
| 2787 |
) ]; |
| 2788 |
} |
| 2789 |
$mod->delete_brand( $brand_id ); |
| 2790 |
return [ 'success' => true, 'data' => array( |
| 2791 |
'deleted' => true, |
| 2792 |
'brand_id' => $brand['id'], |
| 2793 |
'name' => $brand['name'], |
| 2794 |
) ]; |
| 2795 |
} |
| 2796 |
|
| 2797 |
// Post Management |
| 2798 |
case 'mwseo_skip_post': |
| 2799 |
$post = get_post( $args['post_id'] ); |
| 2800 |
if ( !$post ) { |
| 2801 |
return [ 'success' => false, 'error' => 'Post not found' ]; |
| 2802 |
} |
| 2803 |
|
| 2804 |
$skip = $args['skip'] ?? true; |
| 2805 |
|
| 2806 |
if ( $skip ) { |
| 2807 |
update_post_meta( $post->ID, '_mwseo_status', 'skip' ); |
| 2808 |
$message = 'Post marked to skip SEO analysis'; |
| 2809 |
} else { |
| 2810 |
delete_post_meta( $post->ID, '_mwseo_status', 'skip' ); |
| 2811 |
$message = 'Post unmarked from skip list'; |
| 2812 |
} |
| 2813 |
|
| 2814 |
return [ |
| 2815 |
'success' => true, |
| 2816 |
'data' => [ |
| 2817 |
'post_id' => $post->ID, |
| 2818 |
'skipped' => $skip |
| 2819 |
], |
| 2820 |
'message' => $message |
| 2821 |
]; |
| 2822 |
} |
| 2823 |
} |
| 2824 |
catch ( Exception $e ) { |
| 2825 |
return [ 'success' => false, 'error' => $e->getMessage() ]; |
| 2826 |
} |
| 2827 |
|
| 2828 |
return $result; |
| 2829 |
} |
| 2830 |
} |