PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.14
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.14
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-seo-audit.php

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

934 lines 32.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: SEO Plugin Audit
4 *
5 * Provides four MCP tools for auditing SEO data across MetaSync and
6 * active third-party SEO plugins (Yoast, Rank Math, AIOSEO):
7 *
8 * - wordpress_read_seo_plugin_data
9 * - wordpress_seo_plugin_diff
10 * - wordpress_sync_to_active_plugins
11 * - wordpress_detect_seo_conflicts
12 *
13 * @package MetaSync
14 * @subpackage MCP_Server/Tools
15 */
16
17 if (!defined('ABSPATH')) {
18 exit;
19 }
20
21 /**
22 * Helper: detect active SEO plugins.
23 *
24 * Returns an associative array with boolean flags for each supported plugin.
25 */
26 if (!function_exists('metasync_seo_audit_detect_active_plugins')) {
27 function metasync_seo_audit_detect_active_plugins() {
28 $detect = function ($slug) {
29 if (function_exists('metasync_is_plugin_active')) {
30 return metasync_is_plugin_active($slug);
31 }
32 if (!function_exists('is_plugin_active')) {
33 require_once ABSPATH . 'wp-admin/includes/plugin.php';
34 }
35 return is_plugin_active($slug);
36 };
37
38 return [
39 'yoast' => $detect('wordpress-seo/wp-seo.php')
40 || $detect('wordpress-seo-premium/wp-seo-premium.php'),
41 'rank_math' => $detect('seo-by-rank-math/rank-math.php')
42 || $detect('seo-by-rankmath/rank-math.php')
43 || $detect('seo-by-rank-math-pro/rank-math-pro.php'),
44 'aioseo' => $detect('all-in-one-seo-pack/all_in_one_seo_pack.php')
45 || $detect('all-in-one-seo-pack-pro/all_in_one_seo_pack.php'),
46 ];
47 }
48 }
49
50 /**
51 * Tool: wordpress_read_seo_plugin_data
52 *
53 * Reads native meta storage for all active SEO plugins for a given
54 * post and returns a normalized structure. Returns null for plugins
55 * that are not installed (not errors).
56 */
57 class MCP_Tool_Read_SEO_Plugin_Data extends MCP_Tool_Base {
58
59 public function get_name() {
60 return 'wordpress_read_seo_plugin_data';
61 }
62
63 public function get_description() {
64 return 'Read native SEO plugin meta (Yoast, Rank Math, AIOSEO) plus MetaSync values for a post in a normalized structure';
65 }
66
67 public function get_input_schema() {
68 return [
69 'type' => 'object',
70 'properties' => [
71 'post_id' => [
72 'type' => 'integer',
73 'description' => 'WordPress post or page ID',
74 'minimum' => 1,
75 ],
76 ],
77 'required' => ['post_id'],
78 ];
79 }
80
81 public function execute($params) {
82 $this->validate_params($params);
83 $this->require_capability('manage_options');
84
85 $post_id = $this->sanitize_integer($params['post_id']);
86 $this->verify_post_exists($post_id);
87 $this->check_post_permission($post_id);
88
89 $active = metasync_seo_audit_detect_active_plugins();
90
91 $active_list = [];
92 foreach ($active as $slug => $is_active) {
93 if ($is_active) {
94 $active_list[] = $slug;
95 }
96 }
97
98 $metasync_data = $this->read_metasync($post_id);
99 $yoast_data = $active['yoast'] ? $this->read_yoast($post_id) : null;
100 $rank_math_data = $active['rank_math'] ? $this->read_rank_math($post_id) : null;
101 $aioseo_data = $active['aioseo'] ? $this->read_aioseo($post_id) : null;
102
103 return $this->success([
104 'post_id' => $post_id,
105 'active_plugins' => $active_list,
106 'metasync' => $metasync_data,
107 'yoast' => $yoast_data,
108 'rank_math' => $rank_math_data,
109 'aioseo' => $aioseo_data,
110 ]);
111 }
112
113 /**
114 * Read MetaSync meta values. Always returned (MetaSync is always "active").
115 */
116 private function read_metasync($post_id) {
117 $schema = get_post_meta($post_id, 'metasync_schema_markup', true);
118 $schema_types = [];
119 if (!empty($schema)) {
120 $decoded = is_string($schema) ? json_decode($schema, true) : $schema;
121 if (is_array($decoded)) {
122 $this->collect_schema_types($decoded, $schema_types);
123 }
124 }
125
126 return [
127 'title' => get_post_meta($post_id, '_metasync_metatitle', true),
128 'otto_title' => get_post_meta($post_id, '_metasync_otto_title', true),
129 'description' => get_post_meta($post_id, '_metasync_metadesc', true),
130 'otto_description' => get_post_meta($post_id, '_metasync_otto_description', true),
131 'og_title' => get_post_meta($post_id, '_metasync_og_title', true),
132 'og_description' => get_post_meta($post_id, '_metasync_og_description', true),
133 'og_image' => get_post_meta($post_id, '_metasync_og_image', true),
134 'twitter_title' => get_post_meta($post_id, '_metasync_twitter_title', true),
135 'twitter_description' => get_post_meta($post_id, '_metasync_twitter_description', true),
136 'focus_keyword' => get_post_meta($post_id, '_metasync_focus_keyword', true),
137 'canonical' => get_post_meta($post_id, '_metasync_canonical_url', true) ?: null,
138 'robots' => get_post_meta($post_id, '_metasync_robots_index', true) ?: null,
139 'schema_types' => array_values(array_unique($schema_types)),
140 ];
141 }
142
143 /**
144 * Recursively collect @type values from a schema array.
145 */
146 private function collect_schema_types($data, array &$types) {
147 if (!is_array($data)) {
148 return;
149 }
150 if (isset($data['@type'])) {
151 if (is_array($data['@type'])) {
152 foreach ($data['@type'] as $t) {
153 if (is_string($t)) {
154 $types[] = $t;
155 }
156 }
157 } elseif (is_string($data['@type'])) {
158 $types[] = $data['@type'];
159 }
160 }
161 foreach ($data as $value) {
162 if (is_array($value)) {
163 $this->collect_schema_types($value, $types);
164 }
165 }
166 }
167
168 private function read_yoast($post_id) {
169 return [
170 'title' => get_post_meta($post_id, '_yoast_wpseo_title', true),
171 'description' => get_post_meta($post_id, '_yoast_wpseo_metadesc', true),
172 'canonical' => get_post_meta($post_id, '_yoast_wpseo_canonical', true),
173 'robots_noindex' => get_post_meta($post_id, '_yoast_wpseo_meta-robots-noindex', true),
174 'og_title' => get_post_meta($post_id, '_yoast_wpseo_opengraph-title', true),
175 'og_description' => get_post_meta($post_id, '_yoast_wpseo_opengraph-description', true),
176 ];
177 }
178
179 private function read_rank_math($post_id) {
180 return [
181 'title' => get_post_meta($post_id, 'rank_math_title', true),
182 'description' => get_post_meta($post_id, 'rank_math_description', true),
183 'canonical' => get_post_meta($post_id, 'rank_math_canonical_url', true),
184 'robots' => get_post_meta($post_id, 'rank_math_robots', true),
185 'advanced_robots' => get_post_meta($post_id, 'rank_math_advanced_robots', true),
186 'focus_keyword' => get_post_meta($post_id, 'rank_math_focus_keyword', true),
187 'og_title' => get_post_meta($post_id, 'rank_math_facebook_title', true),
188 'og_description' => get_post_meta($post_id, 'rank_math_facebook_description', true),
189 ];
190 }
191
192 private function read_aioseo($post_id) {
193 global $wpdb;
194 try {
195 $table = $wpdb->prefix . 'aioseo_posts';
196 $exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
197 if ($exists !== $table) {
198 return null;
199 }
200
201 $row = $wpdb->get_row(
202 $wpdb->prepare(
203 "SELECT title, description, canonical_url, robots_default, robots_noindex, robots_nofollow, schema_type
204 FROM {$wpdb->prefix}aioseo_posts
205 WHERE post_id = %d",
206 $post_id
207 ),
208 ARRAY_A
209 );
210
211 if (!$row) {
212 return [
213 'title' => null,
214 'description' => null,
215 'canonical' => null,
216 'robots_default' => null,
217 'robots_noindex' => null,
218 'robots_nofollow' => null,
219 'schema_type' => null,
220 ];
221 }
222
223 return [
224 'title' => isset($row['title']) ? $row['title'] : null,
225 'description' => isset($row['description']) ? $row['description'] : null,
226 'canonical' => isset($row['canonical_url']) ? $row['canonical_url'] : null,
227 'robots_default' => isset($row['robots_default']) ? $row['robots_default'] : null,
228 'robots_noindex' => isset($row['robots_noindex']) ? $row['robots_noindex'] : null,
229 'robots_nofollow' => isset($row['robots_nofollow']) ? $row['robots_nofollow'] : null,
230 'schema_type' => isset($row['schema_type']) ? $row['schema_type'] : null,
231 ];
232 } catch (Exception $e) {
233 return null;
234 }
235 }
236 }
237
238 /**
239 * Tool: wordpress_seo_plugin_diff
240 *
241 * Reads MetaSync + active plugin values for a post and categorises
242 * each canonical field as conflict / agreement / migration_opportunity /
243 * sync_gap.
244 */
245 class MCP_Tool_SEO_Plugin_Diff extends MCP_Tool_Base {
246
247 /**
248 * Canonical field map — defines how a logical field maps to each
249 * plugin's stored meta/column key.
250 *
251 * null = plugin does not store this field natively.
252 */
253 private $field_map = [
254 'title' => [
255 'metasync' => ['title', 'otto_title'],
256 'yoast' => 'title',
257 'rank_math' => 'title',
258 'aioseo' => 'title',
259 ],
260 'description' => [
261 'metasync' => ['description', 'otto_description'],
262 'yoast' => 'description',
263 'rank_math' => 'description',
264 'aioseo' => 'description',
265 ],
266 'canonical' => [
267 'metasync' => ['canonical'],
268 'yoast' => 'canonical',
269 'rank_math' => 'canonical',
270 'aioseo' => 'canonical',
271 ],
272 'og_title' => [
273 'metasync' => ['og_title'],
274 'yoast' => 'og_title',
275 'rank_math' => 'og_title',
276 'aioseo' => null,
277 ],
278 'og_description' => [
279 'metasync' => ['og_description'],
280 'yoast' => 'og_description',
281 'rank_math' => 'og_description',
282 'aioseo' => null,
283 ],
284 'focus_keyword' => [
285 'metasync' => ['focus_keyword'],
286 'yoast' => null,
287 'rank_math' => 'focus_keyword',
288 'aioseo' => null,
289 ],
290 'robots' => [
291 'metasync' => ['robots'],
292 'yoast' => 'robots_noindex',
293 'rank_math' => 'robots',
294 'aioseo' => 'robots_noindex',
295 ],
296 ];
297
298 public function get_name() {
299 return 'wordpress_seo_plugin_diff';
300 }
301
302 public function get_description() {
303 return 'Compare MetaSync values against each active SEO plugin and categorise as conflicts, agreements, migration opportunities, or sync gaps';
304 }
305
306 public function get_input_schema() {
307 return [
308 'type' => 'object',
309 'properties' => [
310 'post_id' => [
311 'type' => 'integer',
312 'description' => 'WordPress post or page ID',
313 'minimum' => 1,
314 ],
315 ],
316 'required' => ['post_id'],
317 ];
318 }
319
320 public function execute($params) {
321 $this->validate_params($params);
322 $this->require_capability('manage_options');
323
324 $post_id = $this->sanitize_integer($params['post_id']);
325 $this->verify_post_exists($post_id);
326 $this->check_post_permission($post_id);
327
328 $reader = new MCP_Tool_Read_SEO_Plugin_Data();
329 $raw = $reader->execute(['post_id' => $post_id]);
330 $data = isset($raw['data']) ? $raw['data'] : [];
331
332 $metasync = isset($data['metasync']) ? $data['metasync'] : [];
333 $plugin_data = [
334 'yoast' => isset($data['yoast']) ? $data['yoast'] : null,
335 'rank_math' => isset($data['rank_math']) ? $data['rank_math'] : null,
336 'aioseo' => isset($data['aioseo']) ? $data['aioseo'] : null,
337 ];
338
339 $conflicts = [];
340 $agreements = [];
341 $migration_opportunities = [];
342 $sync_gaps = [];
343
344 foreach ($this->field_map as $field => $mapping) {
345 $metasync_value = $this->resolve_metasync_value($metasync, $mapping['metasync']);
346
347 $plugin_values = [];
348 $field_agreements = [];
349 foreach (['yoast', 'rank_math', 'aioseo'] as $plugin) {
350 if ($plugin_data[$plugin] === null) {
351 continue;
352 }
353 $plugin_key = $mapping[$plugin];
354 if ($plugin_key === null) {
355 continue;
356 }
357 $plugin_value = isset($plugin_data[$plugin][$plugin_key]) ? $plugin_data[$plugin][$plugin_key] : null;
358 $plugin_values[$plugin] = $plugin_value;
359
360 $this->categorise(
361 $field,
362 $plugin,
363 $metasync_value,
364 $plugin_value,
365 $conflicts,
366 $field_agreements,
367 $migration_opportunities,
368 $sync_gaps
369 );
370 }
371
372 // Agreement-across-plugins roll-up: if metasync has a value and every
373 // active plugin that supports the field has the same value, emit a
374 // consolidated "all_plugins: true" agreement entry instead of the
375 // per-plugin entries to avoid double-counting.
376 if (!$this->is_empty($metasync_value) && !empty($plugin_values)) {
377 $all_same = true;
378 foreach ($plugin_values as $pv) {
379 if ($this->is_empty($pv) || (string) $pv !== (string) $metasync_value) {
380 $all_same = false;
381 break;
382 }
383 }
384 if ($all_same) {
385 $agreements[] = [
386 'field' => $field,
387 'value' => $metasync_value,
388 'all_plugins' => true,
389 ];
390 } else {
391 foreach ($field_agreements as $entry) {
392 $agreements[] = $entry;
393 }
394 }
395 } else {
396 foreach ($field_agreements as $entry) {
397 $agreements[] = $entry;
398 }
399 }
400 }
401
402 return $this->success([
403 'post_id' => $post_id,
404 'conflicts' => $conflicts,
405 'agreements' => $agreements,
406 'migration_opportunities' => $migration_opportunities,
407 'sync_gaps' => $sync_gaps,
408 ]);
409 }
410
411 /**
412 * Resolve a MetaSync logical-field value using the first non-empty
413 * candidate key (supports fallback chains such as title → otto_title).
414 */
415 private function resolve_metasync_value($metasync, $keys) {
416 if ($keys === null) {
417 return null;
418 }
419 if (!is_array($keys)) {
420 $keys = [$keys];
421 }
422 foreach ($keys as $key) {
423 if (isset($metasync[$key]) && !$this->is_empty($metasync[$key])) {
424 return $metasync[$key];
425 }
426 }
427 return '';
428 }
429
430 private function is_empty($value) {
431 if ($value === null) {
432 return true;
433 }
434 if (is_array($value)) {
435 return empty($value);
436 }
437 return $value === '' || $value === '0';
438 }
439
440 private function categorise(
441 $field,
442 $plugin,
443 $metasync_value,
444 $plugin_value,
445 array &$conflicts,
446 array &$agreements,
447 array &$migration_opportunities,
448 array &$sync_gaps
449 ) {
450 $ms_empty = $this->is_empty($metasync_value);
451 $pl_empty = $this->is_empty($plugin_value);
452
453 if (!$ms_empty && !$pl_empty) {
454 $ms_str = is_scalar($metasync_value) ? (string) $metasync_value : wp_json_encode($metasync_value);
455 $pl_str = is_scalar($plugin_value) ? (string) $plugin_value : wp_json_encode($plugin_value);
456 if ($ms_str === $pl_str) {
457 $agreements[] = [
458 'field' => $field,
459 'plugin' => $plugin,
460 'value' => $metasync_value,
461 ];
462 } else {
463 $conflicts[] = [
464 'field' => $field,
465 'metasync_value' => $metasync_value,
466 $plugin . '_value' => $plugin_value,
467 'winner' => 'metasync',
468 'reason' => sprintf(
469 'MetaSync filter runs at priority 999 via Metasync_SEO_Conflict_Handler, suppressing %s output when MetaSync has a value',
470 $plugin
471 ),
472 ];
473 }
474 return;
475 }
476
477 if ($ms_empty && !$pl_empty) {
478 $migration_opportunities[] = [
479 'field' => $field,
480 'source' => $plugin,
481 'value' => $plugin_value,
482 'note' => 'MetaSync has no ' . $field . ' set — plugin data can be imported into MetaSync',
483 ];
484 return;
485 }
486
487 if (!$ms_empty && $pl_empty) {
488 $sync_gaps[] = [
489 'field' => $field,
490 'metasync_value' => $metasync_value,
491 $plugin . '_value' => null,
492 'note' => sprintf('MetaSync %s not written to %s (WP-196 sync needed)', $field, $plugin),
493 ];
494 return;
495 }
496 // both empty: nothing to report
497 }
498 }
499
500 /**
501 * Tool: wordpress_sync_to_active_plugins
502 *
503 * Bulk-syncs MetaSync data to each active third-party plugin via
504 * the Metasync_Plugin_Sync class (WP-196). Guarded with class_exists.
505 */
506 class MCP_Tool_Sync_To_Active_Plugins extends MCP_Tool_Base {
507
508 public function get_name() {
509 return 'wordpress_sync_to_active_plugins';
510 }
511
512 public function get_description() {
513 return 'Bulk-sync MetaSync SEO data to all active third-party SEO plugins (requires WP-196 Metasync_Plugin_Sync)';
514 }
515
516 public function get_input_schema() {
517 return [
518 'type' => 'object',
519 'properties' => [
520 'post_ids' => [
521 'type' => 'array',
522 'description' => 'Post IDs to sync',
523 'items' => [
524 'type' => 'integer',
525 'minimum' => 1,
526 ],
527 ],
528 'fields' => [
529 'type' => 'array',
530 'description' => 'Optional list of fields to sync (empty = all)',
531 'items' => [
532 'type' => 'string',
533 ],
534 ],
535 ],
536 'required' => ['post_ids'],
537 ];
538 }
539
540 public function execute($params) {
541 $this->validate_params($params);
542 $this->require_capability('manage_options');
543
544 if (!class_exists('Metasync_Plugin_Sync')) {
545 throw new Exception(
546 'Metasync_Plugin_Sync class not found — this tool requires WP-196 to be merged and active.'
547 );
548 }
549
550 $post_ids = isset($params['post_ids']) && is_array($params['post_ids']) ? $params['post_ids'] : [];
551 $fields = isset($params['fields']) && is_array($params['fields']) ? $params['fields'] : [];
552
553 $syncer = new Metasync_Plugin_Sync();
554 $results = [];
555 $synced = 0;
556
557 foreach ($post_ids as $raw_id) {
558 $post_id = absint($raw_id);
559 if ($post_id < 1) {
560 $results[] = [
561 'post_id' => $raw_id,
562 'success' => false,
563 'details' => 'Invalid post_id',
564 ];
565 continue;
566 }
567
568 try {
569 $this->verify_post_exists($post_id);
570 $this->check_post_permission($post_id);
571 $result = $syncer->sync_post($post_id, $fields);
572 $results[] = [
573 'post_id' => $post_id,
574 'success' => true,
575 'details' => $result,
576 ];
577 $synced++;
578 } catch (Exception $e) {
579 $results[] = [
580 'post_id' => $post_id,
581 'success' => false,
582 'details' => $e->getMessage(),
583 ];
584 }
585 }
586
587 return $this->success([
588 'synced' => $synced,
589 'results' => $results,
590 ]);
591 }
592 }
593
594 /**
595 * Tool: wordpress_detect_seo_conflicts
596 *
597 * Fetches live rendered HTML for a URL, parses the <head>, and reports
598 * duplicate tags, mismatches against stored MetaSync values, and missing
599 * tags. Returns a health score (100 = no issues).
600 */
601 class MCP_Tool_Detect_SEO_Conflicts extends MCP_Tool_Base {
602
603 public function get_name() {
604 return 'wordpress_detect_seo_conflicts';
605 }
606
607 public function get_description() {
608 return 'Fetch live rendered HTML and detect duplicate/mismatched/missing SEO tags vs stored MetaSync values. Returns a health score.';
609 }
610
611 public function get_input_schema() {
612 return [
613 'type' => 'object',
614 'properties' => [
615 'url' => [
616 'type' => 'string',
617 'description' => 'Full URL to audit',
618 ],
619 ],
620 'required' => ['url'],
621 ];
622 }
623
624 public function execute($params) {
625 $this->validate_params($params);
626 $this->require_capability('manage_options');
627
628 $url = $this->sanitize_url($params['url']);
629 if (empty($url)) {
630 throw new InvalidArgumentException('Invalid URL');
631 }
632
633 $response = wp_remote_get($url, [
634 'user-agent' => 'MetaSync-Audit/1.0',
635 'timeout' => 10,
636 ]);
637 if (is_wp_error($response)) {
638 throw new Exception('Failed to fetch URL: ' . $response->get_error_message());
639 }
640
641 $html = wp_remote_retrieve_body($response);
642 if (empty($html)) {
643 throw new Exception('Empty response body for URL');
644 }
645
646 $rendered = $this->parse_head($html);
647 $post_id = url_to_postid($url);
648 if ($post_id) {
649 $this->check_post_permission($post_id);
650 }
651 $stored = $post_id ? $this->read_stored_metasync($post_id) : [];
652
653 $issues = [];
654
655 // Duplicate detection
656 if ($rendered['counts']['title'] > 1) {
657 $issues[] = [
658 'type' => 'duplicate',
659 'tag' => 'title',
660 'count' => $rendered['counts']['title'],
661 'severity' => 'high',
662 ];
663 }
664 if ($rendered['counts']['meta_description'] > 1) {
665 $issues[] = [
666 'type' => 'duplicate',
667 'tag' => 'meta[name=description]',
668 'count' => $rendered['counts']['meta_description'],
669 'severity' => 'high',
670 ];
671 }
672 if ($rendered['counts']['canonical'] > 1) {
673 $issues[] = [
674 'type' => 'duplicate',
675 'tag' => 'link[rel=canonical]',
676 'count' => $rendered['counts']['canonical'],
677 'severity' => 'high',
678 ];
679 }
680 if ($rendered['counts']['robots'] > 1) {
681 $issues[] = [
682 'type' => 'duplicate',
683 'tag' => 'meta[name=robots]',
684 'count' => $rendered['counts']['robots'],
685 'severity' => 'medium',
686 ];
687 }
688 foreach ($rendered['og_counts'] as $prop => $count) {
689 if ($count > 1) {
690 $issues[] = [
691 'type' => 'duplicate',
692 'tag' => 'meta[property=' . $prop . ']',
693 'count' => $count,
694 'severity' => 'high',
695 ];
696 }
697 }
698
699 // Mismatch / missing detection vs stored MetaSync values
700 $comparisons = [
701 'title' => [
702 'stored' => $this->first_non_empty([
703 isset($stored['title']) ? $stored['title'] : '',
704 isset($stored['otto_title']) ? $stored['otto_title'] : '',
705 ]),
706 'rendered' => $rendered['title'],
707 ],
708 'description' => [
709 'stored' => $this->first_non_empty([
710 isset($stored['description']) ? $stored['description'] : '',
711 isset($stored['otto_description']) ? $stored['otto_description'] : '',
712 ]),
713 'rendered' => $rendered['meta_description'],
714 ],
715 'og_title' => [
716 'stored' => isset($stored['og_title']) ? $stored['og_title'] : '',
717 'rendered' => isset($rendered['og']['og:title']) ? $rendered['og']['og:title'] : '',
718 ],
719 'og_description' => [
720 'stored' => isset($stored['og_description']) ? $stored['og_description'] : '',
721 'rendered' => isset($rendered['og']['og:description']) ? $rendered['og']['og:description'] : '',
722 ],
723 'og_image' => [
724 'stored' => isset($stored['og_image']) ? $stored['og_image'] : '',
725 'rendered' => isset($rendered['og']['og:image']) ? $rendered['og']['og:image'] : '',
726 ],
727 ];
728
729 foreach ($comparisons as $field => $pair) {
730 $stored_val = (string) $pair['stored'];
731 $rendered_val = (string) $pair['rendered'];
732 if ($stored_val === '') {
733 continue;
734 }
735 if ($rendered_val === '') {
736 $issues[] = [
737 'type' => 'missing',
738 'field' => $field,
739 'expected' => $stored_val,
740 'severity' => 'low',
741 ];
742 } elseif ($rendered_val !== $stored_val) {
743 $issues[] = [
744 'type' => 'mismatch',
745 'field' => $field,
746 'stored' => $stored_val,
747 'rendered' => $rendered_val,
748 'severity' => 'medium',
749 ];
750 }
751 }
752
753 // Health score
754 $health_score = 100;
755 foreach ($issues as $issue) {
756 switch ($issue['severity']) {
757 case 'high':
758 $health_score -= 15;
759 break;
760 case 'medium':
761 $health_score -= 5;
762 break;
763 case 'low':
764 $health_score -= 2;
765 break;
766 }
767 }
768 if ($health_score < 0) {
769 $health_score = 0;
770 }
771 if ($health_score > 100) {
772 $health_score = 100;
773 }
774
775 return $this->success([
776 'url' => $url,
777 'post_id' => $post_id ? $post_id : null,
778 'rendered_head' => [
779 'title' => $rendered['title'],
780 'meta_description' => $rendered['meta_description'],
781 'canonical' => $rendered['canonical'],
782 'robots' => $rendered['robots'],
783 'og' => $rendered['og'],
784 'schema_types' => $rendered['schema_types'],
785 ],
786 'issues' => $issues,
787 'health_score' => $health_score,
788 ]);
789 }
790
791 /**
792 * Parse the <head> of the rendered HTML using PHP DOMDocument.
793 */
794 private function parse_head($html) {
795 $result = [
796 'title' => '',
797 'meta_description' => '',
798 'canonical' => '',
799 'robots' => '',
800 'og' => [],
801 'schema_types' => [],
802 'counts' => [
803 'title' => 0,
804 'meta_description' => 0,
805 'canonical' => 0,
806 'robots' => 0,
807 ],
808 'og_counts' => [],
809 ];
810
811 if (!class_exists('DOMDocument')) {
812 return $result;
813 }
814
815 $prev = libxml_use_internal_errors(true);
816 $dom = new DOMDocument();
817 @$dom->loadHTML($html);
818 libxml_clear_errors();
819 libxml_use_internal_errors($prev);
820
821 $titles = $dom->getElementsByTagName('title');
822 $result['counts']['title'] = $titles->length;
823 if ($titles->length > 0) {
824 $result['title'] = trim($titles->item(0)->textContent);
825 }
826
827 $metas = $dom->getElementsByTagName('meta');
828 foreach ($metas as $meta) {
829 $name = $meta->getAttribute('name');
830 $property = $meta->getAttribute('property');
831 $content = $meta->getAttribute('content');
832
833 $name_lc = strtolower($name);
834 $property_lc = strtolower($property);
835
836 if ($name_lc === 'description') {
837 $result['counts']['meta_description']++;
838 if ($result['meta_description'] === '') {
839 $result['meta_description'] = $content;
840 }
841 } elseif ($name_lc === 'robots') {
842 $result['counts']['robots']++;
843 if ($result['robots'] === '') {
844 $result['robots'] = $content;
845 }
846 }
847
848 if ($property_lc !== '' && strpos($property_lc, 'og:') === 0) {
849 if (!isset($result['og_counts'][$property_lc])) {
850 $result['og_counts'][$property_lc] = 0;
851 }
852 $result['og_counts'][$property_lc]++;
853 if (!isset($result['og'][$property_lc])) {
854 $result['og'][$property_lc] = $content;
855 }
856 }
857 }
858
859 $links = $dom->getElementsByTagName('link');
860 foreach ($links as $link) {
861 $rel = strtolower($link->getAttribute('rel'));
862 if ($rel === 'canonical') {
863 $result['counts']['canonical']++;
864 if ($result['canonical'] === '') {
865 $result['canonical'] = $link->getAttribute('href');
866 }
867 }
868 }
869
870 $scripts = $dom->getElementsByTagName('script');
871 foreach ($scripts as $script) {
872 $type = strtolower($script->getAttribute('type'));
873 if ($type !== 'application/ld+json') {
874 continue;
875 }
876 $json = trim($script->textContent);
877 if ($json === '') {
878 continue;
879 }
880 $decoded = json_decode($json, true);
881 if (!is_array($decoded)) {
882 continue;
883 }
884 $this->collect_schema_types($decoded, $result['schema_types']);
885 }
886 $result['schema_types'] = array_values(array_unique($result['schema_types']));
887
888 return $result;
889 }
890
891 private function collect_schema_types($data, array &$types) {
892 if (!is_array($data)) {
893 return;
894 }
895 if (isset($data['@type'])) {
896 if (is_array($data['@type'])) {
897 foreach ($data['@type'] as $t) {
898 if (is_string($t)) {
899 $types[] = $t;
900 }
901 }
902 } elseif (is_string($data['@type'])) {
903 $types[] = $data['@type'];
904 }
905 }
906 foreach ($data as $value) {
907 if (is_array($value)) {
908 $this->collect_schema_types($value, $types);
909 }
910 }
911 }
912
913 private function read_stored_metasync($post_id) {
914 return [
915 'title' => get_post_meta($post_id, '_metasync_metatitle', true),
916 'otto_title' => get_post_meta($post_id, '_metasync_otto_title', true),
917 'description' => get_post_meta($post_id, '_metasync_metadesc', true),
918 'otto_description' => get_post_meta($post_id, '_metasync_otto_description', true),
919 'og_title' => get_post_meta($post_id, '_metasync_og_title', true),
920 'og_description' => get_post_meta($post_id, '_metasync_og_description', true),
921 'og_image' => get_post_meta($post_id, '_metasync_og_image', true),
922 ];
923 }
924
925 private function first_non_empty(array $values) {
926 foreach ($values as $v) {
927 if ($v !== null && $v !== '' && $v !== '0') {
928 return $v;
929 }
930 }
931 return '';
932 }
933 }
934