PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.23
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.23
2.7.1 2.7.2 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 All 141 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.23, at wp-mcp-server/tools/class-mcp-tool-otto.php

665 lines 26.3 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 // Report success only when real suggestions were cached. A URL OTTO
149 // holds no data for returns an empty array rather than false, so a
150 // `!== false` check would report a successful warm for a URL with
151 // nothing to warm — inverting the signal you would use to debug this.
152 if ($result === false) {
153 return ['success' => false, 'reason' => 'fetch_failed'];
154 }
155 if (!$tc->has_payload($result)) {
156 return ['success' => false, 'reason' => 'no_suggestions_for_url'];
157 }
158 return true;
159 });
160
161 // Step 2: seo_db_sync
162 $steps['seo_db_sync'] = $run_step(function () use ($url) {
163 if (!function_exists('metasync_process_otto_seo_data')) {
164 return ['success' => false, 'reason' => 'not_available'];
165 }
166 return (bool) metasync_process_otto_seo_data($url);
167 });
168
169 // Step 3: plugin_sync (may not be available yet)
170 $steps['plugin_sync'] = $run_step(function () use ($post_id) {
171 if (!class_exists('Metasync_Plugin_Sync')) {
172 return ['success' => false, 'reason' => 'not_available'];
173 }
174 $sync_result = null;
175 if (method_exists('Metasync_Plugin_Sync', 'sync_post')) {
176 $sync_result = Metasync_Plugin_Sync::sync_post($post_id);
177 } elseif (method_exists('Metasync_Plugin_Sync', 'sync')) {
178 $sync_result = Metasync_Plugin_Sync::sync($post_id);
179 } else {
180 return ['success' => false, 'reason' => 'not_available'];
181 }
182 return $sync_result !== false;
183 });
184
185 // Step 4: cache_purge
186 $steps['cache_purge'] = $run_step(function () use ($url) {
187 if (!class_exists('Metasync_Cache_Purge')) {
188 return ['success' => false, 'reason' => 'not_available'];
189 }
190 Metasync_Cache_Purge::purge_single_url($url);
191 return true;
192 });
193
194 // Step 5: cache_warm
195 $steps['cache_warm'] = $run_step(function () use ($url) {
196 if (!class_exists('Metasync_Cache_Purge')) {
197 return ['success' => false, 'reason' => 'not_available'];
198 }
199 Metasync_Cache_Purge::warm_urls([$url]);
200 return true;
201 });
202
203 // Step 6: cdn_purge
204 $steps['cdn_purge'] = $run_step(function () use ($url) {
205 if (!class_exists('Metasync_Edge_Cache_Purge')) {
206 return ['success' => false, 'reason' => 'not_available'];
207 }
208 Metasync_Edge_Cache_Purge::purge([$url]);
209 return true;
210 });
211
212 return $this->success(
213 [
214 'url' => $url,
215 'post_id' => $post_id ?: null,
216 'steps' => $steps,
217 ],
218 'OTTO optimization pipeline completed'
219 );
220 }
221 }
222
223 /**
224 * Get OTTO Status Tool
225 */
226 class MCP_Tool_Get_Otto_Status extends MCP_Tool_Base {
227
228 public function get_name() {
229 return 'wordpress_get_otto_status';
230 }
231
232 public function get_description() {
233 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.';
234 }
235
236 public function get_input_schema() {
237 return [
238 'type' => 'object',
239 'properties' => [
240 'url' => [
241 'type' => 'string',
242 'description' => 'Absolute URL to inspect. Either url or post_id must be provided.',
243 ],
244 'post_id' => [
245 'type' => 'integer',
246 'description' => 'Post or page ID to inspect. Either url or post_id must be provided.',
247 ],
248 ],
249 'required' => [],
250 ];
251 }
252
253 public function execute($params) {
254 $this->validate_params($params);
255 $this->require_capability('manage_options');
256
257 $has_url = !empty($params['url']);
258 $has_post_id = isset($params['post_id']) && (int) $params['post_id'] > 0;
259
260 if (!$has_url && !$has_post_id) {
261 throw new InvalidArgumentException('Either url or post_id is required');
262 }
263 if ($has_url && $has_post_id) {
264 throw new InvalidArgumentException('Provide only one of url or post_id, not both');
265 }
266
267 if ($has_post_id) {
268 $post_id = $this->sanitize_integer($params['post_id']);
269 $this->verify_post_exists($post_id);
270 $this->check_post_permission($post_id);
271 $url = get_permalink($post_id);
272 } else {
273 $url = $this->sanitize_url($params['url']);
274 $post_id = (int) url_to_postid($url);
275 if ($post_id > 0) {
276 $this->check_post_permission($post_id);
277 }
278 }
279
280 $otto_base = dirname(dirname(dirname(__FILE__))) . '/otto/';
281 if (!class_exists('Metasync_Otto_Config')) {
282 $config_file = $otto_base . 'class-metasync-otto-config.php';
283 if (file_exists($config_file)) {
284 require_once $config_file;
285 }
286 }
287
288 $otto_enabled = class_exists('Metasync_Otto_Config') ? Metasync_Otto_Config::is_otto_enabled() : false;
289 $otto_uuid = class_exists('Metasync_Otto_Config') ? Metasync_Otto_Config::get_otto_uuid() : '';
290
291 // Exclusion status (manual only — reflects user-configured exclusions)
292 $url_excluded = null;
293 if (!empty($url)) {
294 if (!function_exists('metasync_is_otto_url_manually_excluded')) {
295 $pixel_file = $otto_base . 'otto_pixel.php';
296 if (file_exists($pixel_file)) {
297 require_once $pixel_file;
298 }
299 }
300 if (function_exists('metasync_is_otto_url_manually_excluded')) {
301 $url_excluded = (bool) metasync_is_otto_url_manually_excluded($url);
302 }
303 }
304
305 // Transient state
306 $transient = [
307 'warm' => false,
308 'has_suggestions' => false,
309 ];
310 if (!empty($url) && !empty($otto_uuid)) {
311 if (!class_exists('Metasync_Otto_Transient_Cache')) {
312 $transient_file = $otto_base . 'class-metasync-otto-transient-cache.php';
313 if (file_exists($transient_file)) {
314 require_once $transient_file;
315 }
316 }
317 if (class_exists('Metasync_Otto_Transient_Cache')) {
318 $tc = new Metasync_Otto_Transient_Cache($otto_uuid);
319 $stats = $tc->get_stats($url);
320 $transient['warm'] = !empty($stats['has_cache']);
321 $transient['has_suggestions'] = !empty($stats['has_suggestions']);
322 $transient['cache_key'] = isset($stats['cache_key']) ? $stats['cache_key'] : null;
323
324 // Get transient expiry for the OTTO cache
325 global $wpdb;
326 $site_id = is_multisite() ? get_current_blog_id() : 0;
327 $normalized_url = rtrim(strtolower($url), '/');
328 $url_hash = md5($normalized_url);
329 $transient_key = 'otto_suggestions_' . $site_id . '_' . $url_hash;
330 $timeout_key = '_transient_timeout_' . $transient_key;
331 $expiry_raw = $wpdb->get_var(
332 $wpdb->prepare(
333 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
334 $timeout_key
335 )
336 );
337 $cache_expires_at = $expiry_raw ? (int) $expiry_raw : null;
338 $cache_expires_in = $cache_expires_at ? max(0, $cache_expires_at - time()) : null;
339
340 $transient['cache_expires_at'] = $cache_expires_at;
341 $transient['cache_expires_in_seconds'] = $cache_expires_in;
342 }
343 }
344
345 // Last-written OTTO meta values
346 $otto_meta_keys = [
347 '_metasync_otto_title',
348 '_metasync_otto_description',
349 '_metasync_otto_keywords',
350 '_metasync_otto_og_title',
351 '_metasync_otto_og_description',
352 '_metasync_otto_twitter_title',
353 '_metasync_otto_twitter_description',
354 '_metasync_canonical_url',
355 '_metasync_otto_structured_data',
356 '_metasync_otto_image_alt_data',
357 '_metasync_otto_headings_data',
358 '_metasync_otto_last_update',
359 ];
360 $last_written_meta = [];
361 if ($post_id > 0) {
362 foreach ($otto_meta_keys as $key) {
363 $val = get_post_meta($post_id, $key, true);
364 if ($val !== '' && $val !== false && $val !== null) {
365 $last_written_meta[$key] = $val;
366 }
367 }
368 }
369
370 // Persistence settings
371 $persistence_settings = null;
372 if (!class_exists('Metasync_Otto_Persistence_Settings')) {
373 $persist_file = $otto_base . 'class-metasync-otto-persistence-settings.php';
374 if (file_exists($persist_file)) {
375 require_once $persist_file;
376 }
377 }
378 if (class_exists('Metasync_Otto_Persistence_Settings')) {
379 $persistence_settings = Metasync_Otto_Persistence_Settings::get_settings();
380 }
381
382 // Plugin sync timestamp (written by when available)
383 $plugin_sync_timestamp = null;
384 if ($post_id > 0) {
385 $ts = get_post_meta($post_id, '_metasync_plugin_sync_ts', true);
386 $plugin_sync_timestamp = $ts !== '' ? $ts : null;
387 }
388
389 return $this->success(
390 [
391 'otto_enabled' => (bool) $otto_enabled,
392 'url' => $url,
393 'post_id' => $post_id ?: null,
394 'url_excluded' => $url_excluded,
395 'transient' => $transient,
396 'last_written_meta' => $last_written_meta,
397 'persistence_settings' => $persistence_settings,
398 'plugin_sync_timestamp' => $plugin_sync_timestamp,
399 ],
400 'OTTO status retrieved'
401 );
402 }
403 }
404
405 /**
406 * Verify SEO Output Tool
407 */
408 class MCP_Tool_Verify_SEO_Output extends MCP_Tool_Base {
409
410 public function get_name() {
411 return 'wordpress_verify_seo_output';
412 }
413
414 public function get_description() {
415 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.';
416 }
417
418 public function get_input_schema() {
419 return [
420 'type' => 'object',
421 'properties' => [
422 'url' => [
423 'type' => 'string',
424 'description' => 'Absolute URL to fetch and verify.',
425 ],
426 'fields' => [
427 'type' => 'array',
428 'description' => 'Optional list of field categories to include. If omitted, all are returned.',
429 'items' => [
430 'type' => 'string',
431 'enum' => ['title', 'description', 'canonical', 'robots', 'og', 'twitter', 'hreflang', 'schema'],
432 ],
433 ],
434 ],
435 'required' => ['url'],
436 ];
437 }
438
439 public function execute($params) {
440 $this->validate_params($params);
441 $this->require_capability('manage_options');
442
443 $url = $this->sanitize_url($params['url']);
444 if (empty($url)) {
445 throw new InvalidArgumentException('url is required');
446 }
447
448 $fields_filter = [];
449 if (!empty($params['fields']) && is_array($params['fields'])) {
450 foreach ($params['fields'] as $f) {
451 if (is_string($f)) {
452 $fields_filter[] = strtolower($f);
453 }
454 }
455 }
456 $include = function ($category) use ($fields_filter) {
457 return empty($fields_filter) || in_array($category, $fields_filter, true);
458 };
459
460 $post_id = (int) url_to_postid($url);
461
462 // Fetch the live HTML
463 $response = wp_remote_get($url, [
464 'timeout' => 15,
465 'redirection' => 5,
466 'user-agent' => 'MetaSync-Verify/1.0',
467 'sslverify' => false,
468 ]);
469
470 if (is_wp_error($response)) {
471 throw new Exception('HTTP fetch failed: ' . $response->get_error_message());
472 }
473
474 $status = (int) wp_remote_retrieve_response_code($response);
475 $html = (string) wp_remote_retrieve_body($response);
476
477 // Load simplehtmldom via the OTTO HTML class which owns the vendor lib
478 $html_class_file = dirname(dirname(dirname(__FILE__))) . '/otto/Otto_html_class.php';
479 if (file_exists($html_class_file)) {
480 require_once $html_class_file;
481 }
482 if (!class_exists('simplehtmldom\\HtmlDocument')) {
483 throw new Exception('simplehtmldom HtmlDocument is not available');
484 }
485
486 $dom = new simplehtmldom\HtmlDocument($html, true, true, 'UTF-8', false);
487
488 // --- Extract rendered values ---
489 $rendered = [];
490 $duplicates = [];
491
492 // title
493 $title_nodes = $dom->find('title');
494 $title_val = null;
495 if (!empty($title_nodes)) {
496 $title_val = trim($title_nodes[0]->plaintext);
497 if (count($title_nodes) > 1) {
498 $duplicates['title'] = count($title_nodes);
499 }
500 }
501 if ($include('title')) {
502 $rendered['title'] = $title_val;
503 }
504
505 // meta description
506 $desc_nodes = $dom->find('meta[name=description]');
507 $desc_val = null;
508 if (!empty($desc_nodes)) {
509 $desc_val = isset($desc_nodes[0]->content) ? trim($desc_nodes[0]->content) : null;
510 if (count($desc_nodes) > 1) {
511 $duplicates['meta[name=description]'] = count($desc_nodes);
512 }
513 }
514 if ($include('description')) {
515 $rendered['description'] = $desc_val;
516 }
517
518 // canonical
519 $canon_nodes = $dom->find('link[rel=canonical]');
520 $canon_val = null;
521 if (!empty($canon_nodes)) {
522 $canon_val = isset($canon_nodes[0]->href) ? trim($canon_nodes[0]->href) : null;
523 if (count($canon_nodes) > 1) {
524 $duplicates['link[rel=canonical]'] = count($canon_nodes);
525 }
526 }
527 if ($include('canonical')) {
528 $rendered['canonical'] = $canon_val;
529 }
530
531 // robots
532 $robots_nodes = $dom->find('meta[name=robots]');
533 $robots_val = null;
534 if (!empty($robots_nodes)) {
535 $robots_val = isset($robots_nodes[0]->content) ? trim($robots_nodes[0]->content) : null;
536 if (count($robots_nodes) > 1) {
537 $duplicates['meta[name=robots]'] = count($robots_nodes);
538 }
539 }
540 if ($include('robots')) {
541 $rendered['robots'] = $robots_val;
542 }
543
544 // OG tags (property starts with og:)
545 if ($include('og')) {
546 $og_tags = [];
547 foreach ($dom->find('meta') as $m) {
548 $property = isset($m->property) ? (string) $m->property : '';
549 if ($property !== '' && strpos($property, 'og:') === 0) {
550 $og_tags[$property] = isset($m->content) ? (string) $m->content : '';
551 }
552 }
553 $rendered['og'] = $og_tags;
554 }
555
556 // Twitter tags
557 if ($include('twitter')) {
558 $tw_tags = [];
559 foreach ($dom->find('meta') as $m) {
560 $name = isset($m->name) ? (string) $m->name : '';
561 if ($name !== '' && strpos($name, 'twitter:') === 0) {
562 $tw_tags[$name] = isset($m->content) ? (string) $m->content : '';
563 }
564 }
565 $rendered['twitter'] = $tw_tags;
566 }
567
568 // hreflang
569 if ($include('hreflang')) {
570 $hreflang = [];
571 foreach ($dom->find('link[rel=alternate]') as $lnk) {
572 $hl = isset($lnk->hreflang) ? (string) $lnk->hreflang : '';
573 if ($hl !== '') {
574 $hreflang[] = [
575 'hreflang' => $hl,
576 'href' => isset($lnk->href) ? (string) $lnk->href : '',
577 ];
578 }
579 }
580 $rendered['hreflang'] = $hreflang;
581 }
582
583 // JSON-LD schema
584 if ($include('schema')) {
585 $schemas = [];
586 foreach ($dom->find('script[type=application/ld+json]') as $s) {
587 $raw = trim($s->innertext);
588 $decoded = json_decode($raw, true);
589 if (json_last_error() === JSON_ERROR_NONE) {
590 $schemas[] = $decoded;
591 } else {
592 $schemas[] = ['_raw' => $raw, '_parse_error' => json_last_error_msg()];
593 }
594 }
595 $rendered['schema'] = $schemas;
596 }
597
598 // --- Stored meta for comparison ---
599 $stored_meta_keys = [
600 'title' => '_metasync_otto_title',
601 'description' => '_metasync_otto_description',
602 'canonical' => '_metasync_canonical_url',
603 'og_title' => '_metasync_otto_og_title',
604 'og_description' => '_metasync_otto_og_description',
605 'twitter_title' => '_metasync_otto_twitter_title',
606 'twitter_description' => '_metasync_otto_twitter_description',
607 ];
608 $stored_meta = [];
609 if ($post_id > 0) {
610 foreach ($stored_meta_keys as $logical => $meta_key) {
611 $val = get_post_meta($post_id, $meta_key, true);
612 $stored_meta[$logical] = $val !== '' ? $val : null;
613 }
614 } else {
615 foreach ($stored_meta_keys as $logical => $meta_key) {
616 $stored_meta[$logical] = null;
617 }
618 }
619
620 // --- Field-by-field comparison ---
621 $comparison = [];
622
623 $add_comparison = function ($category, $field, $stored_val, $rendered_val) use (&$comparison, $include) {
624 if (!$include($category)) {
625 return;
626 }
627 $comparison[] = [
628 'field' => $field,
629 'match' => ($stored_val === $rendered_val) || ($stored_val === null && $rendered_val === null) || ((string) $stored_val === (string) $rendered_val),
630 'stored' => $stored_val,
631 'rendered' => $rendered_val,
632 ];
633 };
634
635 $add_comparison('title', 'title', $stored_meta['title'], $title_val);
636 $add_comparison('description', 'description', $stored_meta['description'], $desc_val);
637 $add_comparison('canonical', 'canonical', $stored_meta['canonical'], $canon_val);
638
639 if ($include('og')) {
640 $og_rendered = isset($rendered['og']) ? $rendered['og'] : [];
641 $add_comparison('og', 'og:title', $stored_meta['og_title'], isset($og_rendered['og:title']) ? $og_rendered['og:title'] : null);
642 $add_comparison('og', 'og:description', $stored_meta['og_description'], isset($og_rendered['og:description']) ? $og_rendered['og:description'] : null);
643 }
644 if ($include('twitter')) {
645 $tw_rendered = isset($rendered['twitter']) ? $rendered['twitter'] : [];
646 $add_comparison('twitter', 'twitter:title', $stored_meta['twitter_title'], isset($tw_rendered['twitter:title']) ? $tw_rendered['twitter:title'] : null);
647 $add_comparison('twitter', 'twitter:description', $stored_meta['twitter_description'], isset($tw_rendered['twitter:description']) ? $tw_rendered['twitter:description'] : null);
648 }
649
650 return $this->success(
651 [
652 'url' => $url,
653 'post_id' => $post_id ?: null,
654 'http_status' => $status,
655 'fetch_status' => $status >= 200 && $status < 400 ? 'ok' : 'error',
656 'rendered' => $rendered,
657 'stored_meta' => $stored_meta,
658 'comparison' => $comparison,
659 'duplicate_tags' => $duplicates,
660 ],
661 'SEO output verification complete'
662 );
663 }
664 }
665