PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.6
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.6
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.6, at admin/class-metasync-seo-health.php

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