PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.12
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.12
2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 2.6.4 2.6.3 All 139 releases
metasync / wp-mcp-server / tools / class-mcp-tool-otto.php

class-mcp-tool-otto.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.12, at wp-mcp-server/tools/class-mcp-tool-otto.php

655 lines 25.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MCP Tools: OTTO Pipeline Tools
4 *
5 * Provides three MCP tools that close the loop for AI-driven SEO work:
6 * 1. Trigger Otto Optimization — runs the full OTTO pipeline (warm
7 * transient → SEO DB sync → plugin sync → cache purge → cache warm →
8 * CDN purge) for a URL or post.
9 * 2. Get Otto Status — returns the current OTTO state for a URL or post
10 * (enabled flag, exclusion, transient warmth, last-written meta,
11 * persistence settings, plugin sync timestamps).
12 * 3. Verify SEO Output — fetches the live rendered HTML for a URL and
13 * compares the rendered head tags against stored post meta.
14 *
15 * @package MetaSync
16 * @subpackage MCP_Server/Tools
17 */
18
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 /**
24 * Trigger OTTO Optimization Tool
25 */
26 class MCP_Tool_Trigger_Otto_Optimization extends MCP_Tool_Base {
27
28 public function get_name() {
29 return 'wordpress_trigger_otto_optimization';
30 }
31
32 public function get_description() {
33 return 'Trigger the full OTTO optimization pipeline for a URL or post ID: warm transient → SEO DB sync → plugin sync → cache purge → cache warm → CDN purge. Requires OTTO to be enabled and is rate-limited to 10 calls per minute per API key.';
34 }
35
36 public function get_input_schema() {
37 return [
38 'type' => 'object',
39 'properties' => [
40 'url' => [
41 'type' => 'string',
42 'description' => 'Absolute URL to optimize. Either url or post_id must be provided.',
43 ],
44 'post_id' => [
45 'type' => 'integer',
46 'description' => 'Post or page ID to optimize. Either url or post_id must be provided.',
47 ],
48 ],
49 'required' => [],
50 ];
51 }
52
53 public function execute($params) {
54 $this->validate_params($params);
55 $this->require_capability('manage_options');
56
57 $has_url = !empty($params['url']);
58 $has_post_id = isset($params['post_id']) && (int) $params['post_id'] > 0;
59
60 if (!$has_url && !$has_post_id) {
61 throw new InvalidArgumentException('Either url or post_id is required');
62 }
63 if ($has_url && $has_post_id) {
64 throw new InvalidArgumentException('Provide only one of url or post_id, not both');
65 }
66
67 // Resolve url <-> post_id
68 if ($has_post_id) {
69 $post_id = $this->sanitize_integer($params['post_id']);
70 $url = get_permalink($post_id);
71 if (empty($url)) {
72 throw new Exception(sprintf('Unable to resolve permalink for post_id %d', $post_id));
73 }
74 } else {
75 $url = $this->sanitize_url($params['url']);
76 $post_id = (int) url_to_postid($url);
77 }
78
79 // Post-level permission check (when a specific post is targeted)
80 if ($has_post_id && $post_id > 0) {
81 $this->verify_post_exists($post_id);
82 $this->check_post_permission($post_id);
83 }
84
85 // Load OTTO config
86 $otto_base = dirname(dirname(dirname(__FILE__))) . '/otto/';
87 if (!class_exists('Metasync_Otto_Config')) {
88 $config_file = $otto_base . 'class-metasync-otto-config.php';
89 if (file_exists($config_file)) {
90 require_once $config_file;
91 }
92 }
93
94 if (!class_exists('Metasync_Otto_Config') || !Metasync_Otto_Config::is_otto_enabled()) {
95 throw new Exception('OTTO is not enabled for this site.');
96 }
97
98 // Rate-limit: 10 calls per 60s per API key (fall back to IP)
99 if (class_exists('Metasync_Rate_Limiter')) {
100 $options = get_option('metasync_options', []);
101 $api_key = isset($options['general']['apikey']) ? (string) $options['general']['apikey'] : '';
102 $rate_source = $api_key !== '' ? $api_key : (isset($_SERVER['REMOTE_ADDR']) ? (string) $_SERVER['REMOTE_ADDR'] : 'anon');
103 $rate_key = hash('sha256', $rate_source);
104 $rl_result = Metasync_Rate_Limiter::get_instance()->check_rate_limit($rate_key, 10, 60, 'otto_trigger_');
105 if (is_wp_error($rl_result)) {
106 throw new Exception($rl_result->get_error_message());
107 }
108 }
109
110 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
111 $steps = [];
112
113 // Helper: time a callable and return a standardized step result.
114 // The callable returns either a boolean success flag or an array
115 // overriding fields in the result (e.g. ['success'=>false,'reason'=>'x']).
116 $run_step = function (callable $fn) {
117 $start = microtime(true);
118 try {
119 $outcome = $fn();
120 } catch (Exception $e) {
121 return [
122 'success' => false,
123 'error' => $e->getMessage(),
124 'duration_ms' => round((microtime(true) - $start) * 1000, 2),
125 ];
126 }
127 $duration_ms = round((microtime(true) - $start) * 1000, 2);
128 if (is_array($outcome)) {
129 return array_merge(['duration_ms' => $duration_ms], $outcome);
130 }
131 return [
132 'success' => (bool) $outcome,
133 'duration_ms' => $duration_ms,
134 ];
135 };
136
137 // Step 1: warm_transient
138 $steps['warm_transient'] = $run_step(function () use ($otto_base, $otto_uuid, $url) {
139 $transient_file = $otto_base . 'class-metasync-otto-transient-cache.php';
140 if (!class_exists('Metasync_Otto_Transient_Cache') && file_exists($transient_file)) {
141 require_once $transient_file;
142 }
143 if (!class_exists('Metasync_Otto_Transient_Cache') || empty($otto_uuid)) {
144 return ['success' => false, 'reason' => 'not_available'];
145 }
146 $tc = new Metasync_Otto_Transient_Cache($otto_uuid);
147 $result = $tc->warm_cache($url);
148 return $result !== false;
149 });
150
151 // Step 2: seo_db_sync
152 $steps['seo_db_sync'] = $run_step(function () use ($url) {
153 if (!function_exists('metasync_process_otto_seo_data')) {
154 return ['success' => false, 'reason' => 'not_available'];
155 }
156 return (bool) metasync_process_otto_seo_data($url);
157 });
158
159 // Step 3: plugin_sync (WP-196 — may not be available yet)
160 $steps['plugin_sync'] = $run_step(function () use ($post_id) {
161 if (!class_exists('Metasync_Plugin_Sync')) {
162 return ['success' => false, 'reason' => 'not_available'];
163 }
164 $sync_result = null;
165 if (method_exists('Metasync_Plugin_Sync', 'sync_post')) {
166 $sync_result = Metasync_Plugin_Sync::sync_post($post_id);
167 } elseif (method_exists('Metasync_Plugin_Sync', 'sync')) {
168 $sync_result = Metasync_Plugin_Sync::sync($post_id);
169 } else {
170 return ['success' => false, 'reason' => 'not_available'];
171 }
172 return $sync_result !== false;
173 });
174
175 // Step 4: cache_purge
176 $steps['cache_purge'] = $run_step(function () use ($url) {
177 if (!class_exists('Metasync_Cache_Purge')) {
178 return ['success' => false, 'reason' => 'not_available'];
179 }
180 Metasync_Cache_Purge::purge_single_url($url);
181 return true;
182 });
183
184 // Step 5: cache_warm
185 $steps['cache_warm'] = $run_step(function () use ($url) {
186 if (!class_exists('Metasync_Cache_Purge')) {
187 return ['success' => false, 'reason' => 'not_available'];
188 }
189 Metasync_Cache_Purge::warm_urls([$url]);
190 return true;
191 });
192
193 // Step 6: cdn_purge
194 $steps['cdn_purge'] = $run_step(function () use ($url) {
195 if (!class_exists('Metasync_Edge_Cache_Purge')) {
196 return ['success' => false, 'reason' => 'not_available'];
197 }
198 Metasync_Edge_Cache_Purge::purge([$url]);
199 return true;
200 });
201
202 return $this->success(
203 [
204 'url' => $url,
205 'post_id' => $post_id ?: null,
206 'steps' => $steps,
207 ],
208 'OTTO optimization pipeline completed'
209 );
210 }
211 }
212
213 /**
214 * Get OTTO Status Tool
215 */
216 class MCP_Tool_Get_Otto_Status extends MCP_Tool_Base {
217
218 public function get_name() {
219 return 'wordpress_get_otto_status';
220 }
221
222 public function get_description() {
223 return 'Get the current OTTO state for a URL or post: enabled flag, exclusion status, transient warmth, last-written OTTO meta values, persistence settings, and plugin sync timestamps. Works regardless of whether OTTO is enabled.';
224 }
225
226 public function get_input_schema() {
227 return [
228 'type' => 'object',
229 'properties' => [
230 'url' => [
231 'type' => 'string',
232 'description' => 'Absolute URL to inspect. Either url or post_id must be provided.',
233 ],
234 'post_id' => [
235 'type' => 'integer',
236 'description' => 'Post or page ID to inspect. Either url or post_id must be provided.',
237 ],
238 ],
239 'required' => [],
240 ];
241 }
242
243 public function execute($params) {
244 $this->validate_params($params);
245 $this->require_capability('manage_options');
246
247 $has_url = !empty($params['url']);
248 $has_post_id = isset($params['post_id']) && (int) $params['post_id'] > 0;
249
250 if (!$has_url && !$has_post_id) {
251 throw new InvalidArgumentException('Either url or post_id is required');
252 }
253 if ($has_url && $has_post_id) {
254 throw new InvalidArgumentException('Provide only one of url or post_id, not both');
255 }
256
257 if ($has_post_id) {
258 $post_id = $this->sanitize_integer($params['post_id']);
259 $this->verify_post_exists($post_id);
260 $this->check_post_permission($post_id);
261 $url = get_permalink($post_id);
262 } else {
263 $url = $this->sanitize_url($params['url']);
264 $post_id = (int) url_to_postid($url);
265 if ($post_id > 0) {
266 $this->check_post_permission($post_id);
267 }
268 }
269
270 $otto_base = dirname(dirname(dirname(__FILE__))) . '/otto/';
271 if (!class_exists('Metasync_Otto_Config')) {
272 $config_file = $otto_base . 'class-metasync-otto-config.php';
273 if (file_exists($config_file)) {
274 require_once $config_file;
275 }
276 }
277
278 $otto_enabled = class_exists('Metasync_Otto_Config') ? Metasync_Otto_Config::is_otto_enabled() : false;
279 $otto_uuid = class_exists('Metasync_Otto_Config') ? Metasync_Otto_Config::get_otto_uuid() : '';
280
281 // Exclusion status (manual only — reflects user-configured exclusions)
282 $url_excluded = null;
283 if (!empty($url)) {
284 if (!function_exists('metasync_is_otto_url_manually_excluded')) {
285 $pixel_file = $otto_base . 'otto_pixel.php';
286 if (file_exists($pixel_file)) {
287 require_once $pixel_file;
288 }
289 }
290 if (function_exists('metasync_is_otto_url_manually_excluded')) {
291 $url_excluded = (bool) metasync_is_otto_url_manually_excluded($url);
292 }
293 }
294
295 // Transient state
296 $transient = [
297 'warm' => false,
298 'has_suggestions' => false,
299 ];
300 if (!empty($url) && !empty($otto_uuid)) {
301 if (!class_exists('Metasync_Otto_Transient_Cache')) {
302 $transient_file = $otto_base . 'class-metasync-otto-transient-cache.php';
303 if (file_exists($transient_file)) {
304 require_once $transient_file;
305 }
306 }
307 if (class_exists('Metasync_Otto_Transient_Cache')) {
308 $tc = new Metasync_Otto_Transient_Cache($otto_uuid);
309 $stats = $tc->get_stats($url);
310 $transient['warm'] = !empty($stats['has_cache']);
311 $transient['has_suggestions'] = !empty($stats['has_suggestions']);
312 $transient['cache_key'] = isset($stats['cache_key']) ? $stats['cache_key'] : null;
313
314 // Get transient expiry for the OTTO cache
315 global $wpdb;
316 $site_id = is_multisite() ? get_current_blog_id() : 0;
317 $normalized_url = rtrim(strtolower($url), '/');
318 $url_hash = md5($normalized_url);
319 $transient_key = 'otto_suggestions_' . $site_id . '_' . $url_hash;
320 $timeout_key = '_transient_timeout_' . $transient_key;
321 $expiry_raw = $wpdb->get_var(
322 $wpdb->prepare(
323 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
324 $timeout_key
325 )
326 );
327 $cache_expires_at = $expiry_raw ? (int) $expiry_raw : null;
328 $cache_expires_in = $cache_expires_at ? max(0, $cache_expires_at - time()) : null;
329
330 $transient['cache_expires_at'] = $cache_expires_at;
331 $transient['cache_expires_in_seconds'] = $cache_expires_in;
332 }
333 }
334
335 // Last-written OTTO meta values
336 $otto_meta_keys = [
337 '_metasync_otto_title',
338 '_metasync_otto_description',
339 '_metasync_otto_keywords',
340 '_metasync_otto_og_title',
341 '_metasync_otto_og_description',
342 '_metasync_otto_twitter_title',
343 '_metasync_otto_twitter_description',
344 '_metasync_canonical_url',
345 '_metasync_otto_structured_data',
346 '_metasync_otto_image_alt_data',
347 '_metasync_otto_headings_data',
348 '_metasync_otto_last_update',
349 ];
350 $last_written_meta = [];
351 if ($post_id > 0) {
352 foreach ($otto_meta_keys as $key) {
353 $val = get_post_meta($post_id, $key, true);
354 if ($val !== '' && $val !== false && $val !== null) {
355 $last_written_meta[$key] = $val;
356 }
357 }
358 }
359
360 // Persistence settings
361 $persistence_settings = null;
362 if (!class_exists('Metasync_Otto_Persistence_Settings')) {
363 $persist_file = $otto_base . 'class-metasync-otto-persistence-settings.php';
364 if (file_exists($persist_file)) {
365 require_once $persist_file;
366 }
367 }
368 if (class_exists('Metasync_Otto_Persistence_Settings')) {
369 $persistence_settings = Metasync_Otto_Persistence_Settings::get_settings();
370 }
371
372 // Plugin sync timestamp (written by WP-196 when available)
373 $plugin_sync_timestamp = null;
374 if ($post_id > 0) {
375 $ts = get_post_meta($post_id, '_metasync_plugin_sync_ts', true);
376 $plugin_sync_timestamp = $ts !== '' ? $ts : null;
377 }
378
379 return $this->success(
380 [
381 'otto_enabled' => (bool) $otto_enabled,
382 'url' => $url,
383 'post_id' => $post_id ?: null,
384 'url_excluded' => $url_excluded,
385 'transient' => $transient,
386 'last_written_meta' => $last_written_meta,
387 'persistence_settings' => $persistence_settings,
388 'plugin_sync_timestamp' => $plugin_sync_timestamp,
389 ],
390 'OTTO status retrieved'
391 );
392 }
393 }
394
395 /**
396 * Verify SEO Output Tool
397 */
398 class MCP_Tool_Verify_SEO_Output extends MCP_Tool_Base {
399
400 public function get_name() {
401 return 'wordpress_verify_seo_output';
402 }
403
404 public function get_description() {
405 return 'Fetch the live rendered HTML for a URL and extract what Google actually sees (title, description, canonical, robots, OG/Twitter tags, JSON-LD schema, hreflang). Compares rendered values against stored post meta and reports duplicate head tags.';
406 }
407
408 public function get_input_schema() {
409 return [
410 'type' => 'object',
411 'properties' => [
412 'url' => [
413 'type' => 'string',
414 'description' => 'Absolute URL to fetch and verify.',
415 ],
416 'fields' => [
417 'type' => 'array',
418 'description' => 'Optional list of field categories to include. If omitted, all are returned.',
419 'items' => [
420 'type' => 'string',
421 'enum' => ['title', 'description', 'canonical', 'robots', 'og', 'twitter', 'hreflang', 'schema'],
422 ],
423 ],
424 ],
425 'required' => ['url'],
426 ];
427 }
428
429 public function execute($params) {
430 $this->validate_params($params);
431 $this->require_capability('manage_options');
432
433 $url = $this->sanitize_url($params['url']);
434 if (empty($url)) {
435 throw new InvalidArgumentException('url is required');
436 }
437
438 $fields_filter = [];
439 if (!empty($params['fields']) && is_array($params['fields'])) {
440 foreach ($params['fields'] as $f) {
441 if (is_string($f)) {
442 $fields_filter[] = strtolower($f);
443 }
444 }
445 }
446 $include = function ($category) use ($fields_filter) {
447 return empty($fields_filter) || in_array($category, $fields_filter, true);
448 };
449
450 $post_id = (int) url_to_postid($url);
451
452 // Fetch the live HTML
453 $response = wp_remote_get($url, [
454 'timeout' => 15,
455 'redirection' => 5,
456 'user-agent' => 'MetaSync-Verify/1.0',
457 'sslverify' => false,
458 ]);
459
460 if (is_wp_error($response)) {
461 throw new Exception('HTTP fetch failed: ' . $response->get_error_message());
462 }
463
464 $status = (int) wp_remote_retrieve_response_code($response);
465 $html = (string) wp_remote_retrieve_body($response);
466
467 // Load simplehtmldom via the OTTO HTML class which owns the vendor lib
468 $html_class_file = dirname(dirname(dirname(__FILE__))) . '/otto/Otto_html_class.php';
469 if (file_exists($html_class_file)) {
470 require_once $html_class_file;
471 }
472 if (!class_exists('simplehtmldom\\HtmlDocument')) {
473 throw new Exception('simplehtmldom HtmlDocument is not available');
474 }
475
476 $dom = new simplehtmldom\HtmlDocument($html, true, true, 'UTF-8', false);
477
478 // --- Extract rendered values ---
479 $rendered = [];
480 $duplicates = [];
481
482 // title
483 $title_nodes = $dom->find('title');
484 $title_val = null;
485 if (!empty($title_nodes)) {
486 $title_val = trim($title_nodes[0]->plaintext);
487 if (count($title_nodes) > 1) {
488 $duplicates['title'] = count($title_nodes);
489 }
490 }
491 if ($include('title')) {
492 $rendered['title'] = $title_val;
493 }
494
495 // meta description
496 $desc_nodes = $dom->find('meta[name=description]');
497 $desc_val = null;
498 if (!empty($desc_nodes)) {
499 $desc_val = isset($desc_nodes[0]->content) ? trim($desc_nodes[0]->content) : null;
500 if (count($desc_nodes) > 1) {
501 $duplicates['meta[name=description]'] = count($desc_nodes);
502 }
503 }
504 if ($include('description')) {
505 $rendered['description'] = $desc_val;
506 }
507
508 // canonical
509 $canon_nodes = $dom->find('link[rel=canonical]');
510 $canon_val = null;
511 if (!empty($canon_nodes)) {
512 $canon_val = isset($canon_nodes[0]->href) ? trim($canon_nodes[0]->href) : null;
513 if (count($canon_nodes) > 1) {
514 $duplicates['link[rel=canonical]'] = count($canon_nodes);
515 }
516 }
517 if ($include('canonical')) {
518 $rendered['canonical'] = $canon_val;
519 }
520
521 // robots
522 $robots_nodes = $dom->find('meta[name=robots]');
523 $robots_val = null;
524 if (!empty($robots_nodes)) {
525 $robots_val = isset($robots_nodes[0]->content) ? trim($robots_nodes[0]->content) : null;
526 if (count($robots_nodes) > 1) {
527 $duplicates['meta[name=robots]'] = count($robots_nodes);
528 }
529 }
530 if ($include('robots')) {
531 $rendered['robots'] = $robots_val;
532 }
533
534 // OG tags (property starts with og:)
535 if ($include('og')) {
536 $og_tags = [];
537 foreach ($dom->find('meta') as $m) {
538 $property = isset($m->property) ? (string) $m->property : '';
539 if ($property !== '' && strpos($property, 'og:') === 0) {
540 $og_tags[$property] = isset($m->content) ? (string) $m->content : '';
541 }
542 }
543 $rendered['og'] = $og_tags;
544 }
545
546 // Twitter tags
547 if ($include('twitter')) {
548 $tw_tags = [];
549 foreach ($dom->find('meta') as $m) {
550 $name = isset($m->name) ? (string) $m->name : '';
551 if ($name !== '' && strpos($name, 'twitter:') === 0) {
552 $tw_tags[$name] = isset($m->content) ? (string) $m->content : '';
553 }
554 }
555 $rendered['twitter'] = $tw_tags;
556 }
557
558 // hreflang
559 if ($include('hreflang')) {
560 $hreflang = [];
561 foreach ($dom->find('link[rel=alternate]') as $lnk) {
562 $hl = isset($lnk->hreflang) ? (string) $lnk->hreflang : '';
563 if ($hl !== '') {
564 $hreflang[] = [
565 'hreflang' => $hl,
566 'href' => isset($lnk->href) ? (string) $lnk->href : '',
567 ];
568 }
569 }
570 $rendered['hreflang'] = $hreflang;
571 }
572
573 // JSON-LD schema
574 if ($include('schema')) {
575 $schemas = [];
576 foreach ($dom->find('script[type=application/ld+json]') as $s) {
577 $raw = trim($s->innertext);
578 $decoded = json_decode($raw, true);
579 if (json_last_error() === JSON_ERROR_NONE) {
580 $schemas[] = $decoded;
581 } else {
582 $schemas[] = ['_raw' => $raw, '_parse_error' => json_last_error_msg()];
583 }
584 }
585 $rendered['schema'] = $schemas;
586 }
587
588 // --- Stored meta for comparison ---
589 $stored_meta_keys = [
590 'title' => '_metasync_otto_title',
591 'description' => '_metasync_otto_description',
592 'canonical' => '_metasync_canonical_url',
593 'og_title' => '_metasync_otto_og_title',
594 'og_description' => '_metasync_otto_og_description',
595 'twitter_title' => '_metasync_otto_twitter_title',
596 'twitter_description' => '_metasync_otto_twitter_description',
597 ];
598 $stored_meta = [];
599 if ($post_id > 0) {
600 foreach ($stored_meta_keys as $logical => $meta_key) {
601 $val = get_post_meta($post_id, $meta_key, true);
602 $stored_meta[$logical] = $val !== '' ? $val : null;
603 }
604 } else {
605 foreach ($stored_meta_keys as $logical => $meta_key) {
606 $stored_meta[$logical] = null;
607 }
608 }
609
610 // --- Field-by-field comparison ---
611 $comparison = [];
612
613 $add_comparison = function ($category, $field, $stored_val, $rendered_val) use (&$comparison, $include) {
614 if (!$include($category)) {
615 return;
616 }
617 $comparison[] = [
618 'field' => $field,
619 'match' => ($stored_val === $rendered_val) || ($stored_val === null && $rendered_val === null) || ((string) $stored_val === (string) $rendered_val),
620 'stored' => $stored_val,
621 'rendered' => $rendered_val,
622 ];
623 };
624
625 $add_comparison('title', 'title', $stored_meta['title'], $title_val);
626 $add_comparison('description', 'description', $stored_meta['description'], $desc_val);
627 $add_comparison('canonical', 'canonical', $stored_meta['canonical'], $canon_val);
628
629 if ($include('og')) {
630 $og_rendered = isset($rendered['og']) ? $rendered['og'] : [];
631 $add_comparison('og', 'og:title', $stored_meta['og_title'], isset($og_rendered['og:title']) ? $og_rendered['og:title'] : null);
632 $add_comparison('og', 'og:description', $stored_meta['og_description'], isset($og_rendered['og:description']) ? $og_rendered['og:description'] : null);
633 }
634 if ($include('twitter')) {
635 $tw_rendered = isset($rendered['twitter']) ? $rendered['twitter'] : [];
636 $add_comparison('twitter', 'twitter:title', $stored_meta['twitter_title'], isset($tw_rendered['twitter:title']) ? $tw_rendered['twitter:title'] : null);
637 $add_comparison('twitter', 'twitter:description', $stored_meta['twitter_description'], isset($tw_rendered['twitter:description']) ? $tw_rendered['twitter:description'] : null);
638 }
639
640 return $this->success(
641 [
642 'url' => $url,
643 'post_id' => $post_id ?: null,
644 'http_status' => $status,
645 'fetch_status' => $status >= 200 && $status < 400 ? 'ok' : 'error',
646 'rendered' => $rendered,
647 'stored_meta' => $stored_meta,
648 'comparison' => $comparison,
649 'duplicate_tags' => $duplicates,
650 ],
651 'SEO output verification complete'
652 );
653 }
654 }
655