PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.25
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.25
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 / admin / class-metasync-seo-health.php

class-metasync-seo-health.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.25, at admin/class-metasync-seo-health.php

508 lines 14.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // If this file is called directly, abort.
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * SEO Health Dashboard
9 *
10 * Provides a bird's-eye view of all posts/pages and their SEO completeness.
11 *
12 * @since 3.0.0
13 * @package Metasync
14 * @subpackage Metasync/admin
15 */
16 class Metasync_SEO_Health
17 {
18 /** @var self|null */
19 private static $instance = null;
20
21 /**
22 * Third-party and legacy keys checked after MetaSync's own chain.
23 *
24 * MetaSync's tiers are not listed here — they come from
25 * Metasync_Seo_Precedence, the one place the order is defined. These are the
26 * cross-plugin keys SEO Health looks at once MetaSync has nothing.
27 */
28 const THIRD_PARTY_TITLE_KEYS = array(
29 '_yoast_wpseo_title' => 'Yoast',
30 'rank_math_title' => 'Rank Math',
31 '_aioseo_title' => 'AIOSEO',
32 '_metasync_og_title' => 'OG',
33 );
34
35 /**
36 * @see THIRD_PARTY_TITLE_KEYS
37 */
38 const THIRD_PARTY_DESC_KEYS = array(
39 '_yoast_wpseo_metadesc' => 'Yoast',
40 'rank_math_description' => 'Rank Math',
41 '_aioseo_description' => 'AIOSEO',
42 'meta_description' => '',
43 '_metasync_og_description' => 'OG',
44 );
45
46 /**
47 * Meta keys for cross-plugin SEO title detection, highest tier first.
48 *
49 * `_metasync_seo_title` leads: it is the SEO sidebar / Classic meta box
50 * field and outranks everything else. The meta box save writes ONLY that key
51 * and never mirrors into `_metasync_metatitle`, so omitting it would make
52 * the resolver report OTTO's title while the customer's own value is what
53 * actually renders.
54 *
55 * @return array<string,string> Meta key => display source label.
56 */
57 public static function title_meta_keys()
58 {
59 return Metasync_Seo_Precedence::chain(Metasync_Seo_Precedence::FIELD_TITLE)
60 + self::THIRD_PARTY_TITLE_KEYS;
61 }
62
63 /**
64 * Meta keys for cross-plugin SEO description detection, highest tier first.
65 *
66 * `_metasync_imported_seo_desc` sits BELOW OTTO: it holds values brought in
67 * by the external importer, which must not displace an OTTO recommendation.
68 *
69 * @return array<string,string> Meta key => display source label.
70 */
71 public static function desc_meta_keys()
72 {
73 return Metasync_Seo_Precedence::chain(Metasync_Seo_Precedence::FIELD_DESCRIPTION)
74 + self::THIRD_PARTY_DESC_KEYS;
75 }
76
77 /**
78 * Transient key for summary stats.
79 * Versioned by a hash of the meta key constants — auto-invalidates when fallback chain changes.
80 */
81 const STATS_TRANSIENT_KEY = 'metasync_seo_health_stats';
82
83 /**
84 * Get the versioned transient key.
85 * Changes automatically whenever the resolved key chain is modified.
86 *
87 * @return string
88 */
89 private static function get_stats_transient_key()
90 {
91 $fingerprint = md5(serialize(self::title_meta_keys()) . serialize(self::desc_meta_keys()));
92 return self::STATS_TRANSIENT_KEY . '_' . substr($fingerprint, 0, 8);
93 }
94
95 /**
96 * Builder/internal post types that should never appear in SEO Health.
97 */
98 private static $excluded_post_types = array(
99 'elementor_library',
100 'e-floating-buttons',
101 'e-landing-page',
102 'et_pb_layout',
103 'et_header_layout',
104 'et_body_layout',
105 'et_footer_layout',
106 'oxy_user_library',
107 'ct_template',
108 'wp_template',
109 'wp_template_part',
110 'wp_global_styles',
111 'wp_navigation',
112 'wp_block',
113 'custom_css',
114 'customize_changeset',
115 'revision',
116 'nav_menu_item',
117 );
118
119 /**
120 * Get singleton instance.
121 *
122 * @return self
123 */
124 public static function get_instance()
125 {
126 if (self::$instance === null) {
127 self::$instance = new self();
128 }
129 return self::$instance;
130 }
131
132 private function __construct()
133 {
134 add_action('save_post', array($this, 'invalidate_cache'));
135 }
136
137 /**
138 * Handle CSV export. Called from admin_init guard in class-metasync.php
139 * — only when $_GET parameters already match.
140 */
141 public function handle_csv_export()
142 {
143 if (!wp_verify_nonce($_GET['_wpnonce'], 'metasync_seo_health_export')) {
144 wp_die(__('Security check failed.', 'metasync'));
145 }
146
147 if (!current_user_can('manage_options')) {
148 wp_die(__('You do not have permission to export.', 'metasync'));
149 }
150
151 $this->export_csv();
152 }
153
154 /**
155 * Get supported post types including WooCommerce products and custom post types.
156 * Excludes builder/internal post types (Elementor templates, Divi layouts, etc.).
157 *
158 * @return array
159 */
160 public static function get_supported_post_types()
161 {
162 $post_types = array('post', 'page');
163
164 if (class_exists('WooCommerce')) {
165 $post_types[] = 'product';
166 }
167
168 $custom_types = get_post_types(array(
169 'public' => true,
170 'publicly_queryable' => true,
171 '_builtin' => false,
172 ), 'names');
173
174 foreach ($custom_types as $cpt) {
175 if (
176 !in_array($cpt, $post_types, true) &&
177 !in_array($cpt, self::$excluded_post_types, true)
178 ) {
179 $post_types[] = $cpt;
180 }
181 }
182
183 return $post_types;
184 }
185
186 /**
187 * Get the first non-empty SEO meta value across plugin fallbacks.
188 *
189 * @param int $post_id Post ID.
190 * @param string $field Either 'title' or 'description'.
191 * @return array Array with 'value' and 'source' keys.
192 */
193 public static function get_seo_meta_with_fallback($post_id, $field)
194 {
195 $meta_keys = ($field === 'title') ? self::title_meta_keys() : self::desc_meta_keys();
196
197 foreach ($meta_keys as $meta_key => $source) {
198 $value = get_post_meta($post_id, $meta_key, true);
199 if (!empty($value)) {
200 // White-label the OTTO source label at render time (const values stay literal).
201 if ($source === 'OTTO') {
202 $source = Metasync::get_whitelabel_otto_name();
203 }
204 return array('value' => $value, 'source' => $source);
205 }
206 }
207
208 return array('value' => '', 'source' => '');
209 }
210
211 /**
212 * Check if a post has an OG image (MetaSync or featured image fallback).
213 *
214 * @param int $post_id Post ID.
215 * @return bool
216 */
217 public static function has_og_image($post_id)
218 {
219 $og_image = get_post_meta($post_id, '_metasync_og_image', true);
220 if (!empty($og_image)) {
221 return true;
222 }
223 return !empty(get_post_meta($post_id, '_thumbnail_id', true));
224 }
225
226 /**
227 * Check if a post has meaningful schema markup.
228 * Treats empty arrays/objects as "no schema".
229 *
230 * @param int $post_id Post ID.
231 * @return bool
232 */
233 public static function has_schema($post_id)
234 {
235 $schema = get_post_meta($post_id, 'metasync_schema_markup', true);
236 if (empty($schema)) {
237 return false;
238 }
239 if (is_string($schema)) {
240 $decoded = json_decode($schema, true);
241 return is_array($decoded) && !empty($decoded);
242 }
243 if (is_array($schema)) {
244 return !empty($schema);
245 }
246 return false;
247 }
248
249 /**
250 * Calculate alt text coverage for images in post content.
251 *
252 * @param string $content Post content HTML.
253 * @return array Array with 'total', 'with_alt', and 'percentage' keys.
254 */
255 public static function calculate_alt_text_coverage($content)
256 {
257 $result = array('total' => 0, 'with_alt' => 0, 'percentage' => null);
258
259 if (!preg_match_all('/<img[^>]*>/i', $content, $matches)) {
260 return $result;
261 }
262
263 $result['total'] = count($matches[0]);
264 foreach ($matches[0] as $img_tag) {
265 if (preg_match('/alt\s*=\s*["\']([^"\']+)["\']/i', $img_tag, $alt_match)) {
266 if (!empty(trim($alt_match[1]))) {
267 $result['with_alt']++;
268 }
269 }
270 }
271
272 $result['percentage'] = $result['total'] > 0
273 ? round(($result['with_alt'] / $result['total']) * 100)
274 : 100;
275
276 return $result;
277 }
278
279 /**
280 * Render the SEO Health dashboard page.
281 * Uses the 3-column sidebar layout (render_layout_open/close).
282 */
283 public function render_page()
284 {
285 require_once plugin_dir_path(__FILE__) . 'class-metasync-seo-health-list-table.php';
286
287 $stats = $this->get_summary_stats();
288 $list_table = new Metasync_SEO_Health_List_Table();
289 $list_table->prepare_items();
290
291 $nav = Metasync_Admin_Navigation::instance();
292 $nav->render_layout_open(
293 'SEO Health',
294 'seo_health',
295 __('Bird\'s-eye view of SEO completeness across all your content.', 'metasync')
296 );
297 ?>
298
299 <div class="metasync-seo-health-summary">
300 <?php
301 $stat_cards = array(
302 array('value' => $stats['total_posts'], 'label' => __('Total Posts', 'metasync'), 'type' => 'count'),
303 array('value' => $stats['pct_seo_title'], 'label' => __('SEO Title Set', 'metasync'), 'type' => 'percent'),
304 array('value' => $stats['pct_meta_description'], 'label' => __('Meta Description Set', 'metasync'), 'type' => 'percent'),
305 array('value' => $stats['pct_schema'], 'label' => __('Schema Markup', 'metasync'), 'type' => 'percent'),
306 array('value' => $stats['pct_og_image'], 'label' => __('OG Image Set', 'metasync'), 'type' => 'percent'),
307 );
308 foreach ($stat_cards as $card):
309 $display = ($card['type'] === 'percent') ? $card['value'] . '%' : $card['value'];
310 $color_class = 'stat-neutral';
311 if ($card['type'] === 'percent') {
312 if ($card['value'] >= 80) {
313 $color_class = 'stat-success';
314 } elseif ($card['value'] >= 50) {
315 $color_class = 'stat-warning';
316 } else {
317 $color_class = 'stat-error';
318 }
319 }
320 ?>
321 <div class="metasync-health-stat-card">
322 <div class="stat-value <?php echo $color_class; ?>"><?php echo esc_html($display); ?></div>
323 <div class="stat-label"><?php echo esc_html($card['label']); ?></div>
324 </div>
325 <?php endforeach; ?>
326 </div>
327
328 <form method="get">
329 <input type="hidden" name="page" value="<?php echo esc_attr($_GET['page'] ?? ''); ?>" />
330
331 <div class="metasync-health-toolbar">
332 <a href="<?php echo esc_url(wp_nonce_url(add_query_arg('export', 'csv'), 'metasync_seo_health_export')); ?>" class="button button-secondary">
333 <?php esc_html_e('Export CSV', 'metasync'); ?>
334 </a>
335 <?php $list_table->search_box(__('Search Posts', 'metasync'), 'seo-health-search'); ?>
336 </div>
337
338 <?php $list_table->display(); ?>
339 </form>
340
341 <?php
342 $nav->render_layout_close();
343 }
344
345 /**
346 * Get summary statistics, cached in a transient.
347 * Uses WP_Query with meta_query for counting (no raw SQL).
348 * Primes the meta cache in one batch to avoid N+1 lookups.
349 *
350 * @return array
351 */
352 public function get_summary_stats()
353 {
354 $stats = get_transient(self::get_stats_transient_key());
355
356 if ($stats !== false) {
357 return $stats;
358 }
359
360 $post_types = self::get_supported_post_types();
361
362 // Fetch all post IDs in one query. Exclude custom/LPS pages so the score
363 // percentages below reflect only WordPress-managed pages — they
364 // carry self-contained SEO in their HTML, not WP post meta, and would
365 // otherwise drag every percentage down as false "not set".
366 $query = new WP_Query(array(
367 'post_type' => $post_types,
368 'post_status' => array('publish', 'draft'),
369 'posts_per_page' => -1,
370 'fields' => 'ids',
371 'no_found_rows' => true,
372 'meta_query' => array(metasync_get_custom_page_exclusion_meta_query()),
373 ));
374
375 $post_ids = $query->posts;
376 $total = count($post_ids);
377
378 if ($total === 0) {
379 $stats = array(
380 'total_posts' => 0,
381 'pct_seo_title' => 0,
382 'pct_meta_description' => 0,
383 'pct_schema' => 0,
384 'pct_og_image' => 0,
385 );
386 set_transient(self::get_stats_transient_key(), $stats, 3600);
387 return $stats;
388 }
389
390 // Prime meta cache in one DB call — all subsequent get_post_meta() are free
391 update_meta_cache('post', $post_ids);
392
393 $with_title = 0;
394 $with_desc = 0;
395 $with_schema = 0;
396 $with_og_image = 0;
397
398 foreach ($post_ids as $post_id) {
399 if (!empty(self::get_seo_meta_with_fallback($post_id, 'title')['value'])) {
400 $with_title++;
401 }
402
403 if (!empty(self::get_seo_meta_with_fallback($post_id, 'description')['value'])) {
404 $with_desc++;
405 }
406
407 if (self::has_schema($post_id)) {
408 $with_schema++;
409 }
410
411 if (self::has_og_image($post_id)) {
412 $with_og_image++;
413 }
414 }
415
416 $stats = array(
417 'total_posts' => $total,
418 'pct_seo_title' => round(($with_title / $total) * 100),
419 'pct_meta_description' => round(($with_desc / $total) * 100),
420 'pct_schema' => round(($with_schema / $total) * 100),
421 'pct_og_image' => round(($with_og_image / $total) * 100),
422 );
423
424 set_transient(self::get_stats_transient_key(), $stats, 3600);
425
426 return $stats;
427 }
428
429 /**
430 * Invalidate the summary stats transient on post save.
431 */
432 public function invalidate_cache()
433 {
434 delete_transient(self::get_stats_transient_key());
435 }
436
437 /**
438 * Export filtered results as CSV.
439 */
440 public function export_csv()
441 {
442 require_once plugin_dir_path(__FILE__) . 'class-metasync-seo-health-list-table.php';
443
444 header('Content-Type: text/csv; charset=utf-8');
445 header('Content-Disposition: attachment; filename=seo-health-export.csv');
446
447 $output = fopen('php://output', 'w');
448
449 fputcsv($output, array(
450 'Post ID', 'Title', 'Post Type', 'Status',
451 'SEO Title', 'SEO Title Source',
452 'Meta Description', 'Meta Description Source',
453 'Has Schema', 'Has OG Image',
454 'Alt Text Coverage %', 'Last Modified',
455 ));
456
457 $args = array(
458 'post_type' => self::get_supported_post_types(),
459 'post_status' => array('publish', 'draft'),
460 'posts_per_page' => -1,
461 );
462
463 $post_type_filter = isset($_GET['post_type_filter']) ? sanitize_text_field($_GET['post_type_filter']) : '';
464 if (!empty($post_type_filter) && in_array($post_type_filter, self::get_supported_post_types(), true)) {
465 $args['post_type'] = $post_type_filter;
466 }
467
468 $status_filter = isset($_GET['status_filter']) ? sanitize_text_field($_GET['status_filter']) : '';
469 if (!empty($status_filter) && in_array($status_filter, array('publish', 'draft'), true)) {
470 $args['post_status'] = $status_filter;
471 }
472
473 $search = isset($_GET['s']) ? sanitize_text_field($_GET['s']) : '';
474 if (!empty($search)) {
475 $args['s'] = $search;
476 }
477
478 // Exclude custom/LPS pages so the export matches the on-screen table.
479 $args['meta_query'] = array(metasync_get_custom_page_exclusion_meta_query());
480
481 $query = new WP_Query($args);
482
483 foreach ($query->posts as $post) {
484 $title_result = self::get_seo_meta_with_fallback($post->ID, 'title');
485 $desc_result = self::get_seo_meta_with_fallback($post->ID, 'description');
486 $alt = self::calculate_alt_text_coverage($post->post_content);
487
488 fputcsv($output, array(
489 $post->ID,
490 $post->post_title,
491 $post->post_type,
492 $post->post_status,
493 $title_result['value'],
494 $title_result['source'],
495 $desc_result['value'],
496 $desc_result['source'],
497 self::has_schema($post->ID) ? 'Yes' : 'No',
498 self::has_og_image($post->ID) ? 'Yes' : 'No',
499 $alt['percentage'] !== null ? $alt['percentage'] : 'N/A',
500 get_the_modified_date('Y-m-d', $post),
501 ));
502 }
503
504 fclose($output);
505 exit;
506 }
507 }
508