PluginProbe
SEO Engine – Smart SEO with AI, Schema & Redirection for WordPress / 0.9.4
SEO Engine – Smart SEO with AI, Schema & Redirection for WordPress v0.9.4
0.9.4 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 0.8.6 0.8.5 0.8.4 0.8.2 0.8.1 0.7.9 0.8.0 0.7.7 0.7.8 0.7.6 0.7.5 0.7.4 0.7.3 0.7.2 0.7.1 0.7.0 0.6.5 All 88 releases
seo-engine / classes / mcp.php

mcp.php in SEO Engine – Smart SEO with AI, Schema & Redirection for WordPress 0.9.4, at classes/mcp.php

2,830 lines 127.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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', ' [&hellip;]' );
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 &amp; 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 }