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

741 lines 21.7 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 * Third-party title keys, limited to plugins that are actually active.
79 *
80 * A deactivated plugin leaves its meta behind; counting that as a set
81 * title reports a value nothing can render anymore. The gate keeps the
82 * table, the missing filters and the summary cards on the same key set —
83 * they all read through these maps.
84 *
85 * @return array<string,string> Meta key => display source label.
86 */
87 private static function third_party_title_keys()
88 {
89 return self::filter_keys_by_active_plugin(self::THIRD_PARTY_TITLE_KEYS);
90 }
91
92 /**
93 * @see third_party_title_keys()
94 *
95 * @return array<string,string> Meta key => display source label.
96 */
97 private static function third_party_desc_keys()
98 {
99 return self::filter_keys_by_active_plugin(self::THIRD_PARTY_DESC_KEYS);
100 }
101
102 /**
103 * Drop cross-plugin keys whose owning plugin is not active.
104 *
105 * MetaSync's own keys (the OG pair, the legacy description key) carry no
106 * plugin and always stay. is_plugin_active() lives in wp-admin even on
107 * frontend and REST requests, so it is required on demand the same way the
108 * conflict handler does it.
109 *
110 * @param array<string,string> $keys Meta key => plugin-derived source label.
111 * @return array<string,string>
112 */
113 private static function filter_keys_by_active_plugin(array $keys)
114 {
115 $active = array();
116
117 if (self::is_plugin_active_any(array(
118 'wordpress-seo/wp-seo.php',
119 'wordpress-seo-premium/wp-seo-premium.php',
120 ))) {
121 $active['Yoast'] = true;
122 }
123 if (self::is_plugin_active_any(array(
124 'seo-by-rank-math/rank-math.php',
125 'seo-by-rankmath/rank-math.php',
126 ))) {
127 $active['Rank Math'] = true;
128 }
129 if (self::is_plugin_active_any(array(
130 'all-in-one-seo-pack/all_in_one_seo_pack.php',
131 'all-in-one-seo-pack-pro/all_in_one_seo_pack.php',
132 ))) {
133 $active['AIOSEO'] = true;
134 }
135
136 $plugin_labels = array('Yoast', 'Rank Math', 'AIOSEO');
137
138 return array_filter(
139 $keys,
140 static function ($source) use ($plugin_labels, $active) {
141 // Labels the plugin does not own are never gated.
142 if (!in_array($source, $plugin_labels, true)) {
143 return true;
144 }
145 return isset($active[$source]);
146 }
147 );
148 }
149
150 /**
151 * Whether any of the given plugin basenames is active.
152 *
153 * @param string[] $plugins Plugin basename paths.
154 * @return bool
155 */
156 private static function is_plugin_active_any(array $plugins)
157 {
158 if (!function_exists('is_plugin_active')) {
159 require_once ABSPATH . 'wp-admin/includes/plugin.php';
160 }
161
162 foreach ($plugins as $plugin) {
163 if (is_plugin_active($plugin)) {
164 return true;
165 }
166 }
167
168 return false;
169 }
170
171 /**
172 * Transient key for summary stats.
173 * Versioned by a hash of the meta key constants — auto-invalidates when fallback chain changes.
174 */
175 const STATS_TRANSIENT_KEY = 'metasync_seo_health_stats';
176
177 /**
178 * Get the versioned transient key.
179 * Changes automatically whenever the resolved key chain is modified.
180 *
181 * @return string
182 */
183 private static function get_stats_transient_key()
184 {
185 $fingerprint = md5(serialize(self::title_meta_keys()) . serialize(self::desc_meta_keys()));
186 return self::STATS_TRANSIENT_KEY . '_' . substr($fingerprint, 0, 8);
187 }
188
189 /**
190 * Builder/internal post types that should never appear in SEO Health.
191 */
192 private static $excluded_post_types = array(
193 'elementor_library',
194 'e-floating-buttons',
195 'e-landing-page',
196 'et_pb_layout',
197 'et_header_layout',
198 'et_body_layout',
199 'et_footer_layout',
200 'oxy_user_library',
201 'ct_template',
202 'wp_template',
203 'wp_template_part',
204 'wp_global_styles',
205 'wp_navigation',
206 'wp_block',
207 'custom_css',
208 'customize_changeset',
209 'revision',
210 'nav_menu_item',
211 );
212
213 /**
214 * Get singleton instance.
215 *
216 * @return self
217 */
218 public static function get_instance()
219 {
220 if (self::$instance === null) {
221 self::$instance = new self();
222 }
223 return self::$instance;
224 }
225
226 private function __construct()
227 {
228 add_action('save_post', array($this, 'invalidate_cache'));
229 }
230
231 /**
232 * Handle CSV export. Called from admin_init guard in class-metasync.php
233 * — only when $_GET parameters already match.
234 */
235 public function handle_csv_export()
236 {
237 if (!wp_verify_nonce($_GET['_wpnonce'], 'metasync_seo_health_export')) {
238 wp_die(esc_html__('Security check failed.', 'metasync'));
239 }
240
241 if (!current_user_can('manage_options')) {
242 wp_die(esc_html__('You do not have permission to export.', 'metasync'));
243 }
244
245 $this->export_csv();
246 }
247
248 /**
249 * Get supported post types including WooCommerce products and custom post types.
250 * Excludes builder/internal post types (Elementor templates, Divi layouts, etc.).
251 *
252 * @return array
253 */
254 public static function get_supported_post_types()
255 {
256 $post_types = array('post', 'page');
257
258 if (class_exists('WooCommerce')) {
259 $post_types[] = 'product';
260 }
261
262 $custom_types = get_post_types(array(
263 'public' => true,
264 'publicly_queryable' => true,
265 '_builtin' => false,
266 ), 'names');
267
268 foreach ($custom_types as $cpt) {
269 if (
270 !in_array($cpt, $post_types, true) &&
271 !in_array($cpt, self::$excluded_post_types, true)
272 ) {
273 $post_types[] = $cpt;
274 }
275 }
276
277 return $post_types;
278 }
279
280 /**
281 * Get the SEO meta value that applies to a post, and where it came from.
282 *
283 * Read-only. MetaSync's own tiers (customer value, OTTO, imported) are
284 * settled by Metasync_Seo_Precedence::resolve() — the one place the order
285 * lives — which also drops the OTTO tiers when OTTO is switched off for
286 * the post. This function never fetches OTTO data, compares freshness, or
287 * writes anything: it reports what is stored, and only that.
288 *
289 * Below the MetaSync chain come the cross-plugin and legacy keys, gated to
290 * plugins that are actually active — a deactivated plugin's leftover meta
291 * cannot control output, so it does not count as set here.
292 *
293 * @param int $post_id Post ID.
294 * @param string $field Either 'title' or 'description'.
295 * @return array Array with 'value' and 'source' keys.
296 */
297 public static function get_seo_meta_with_fallback($post_id, $field)
298 {
299 $field_const = ($field === 'title')
300 ? Metasync_Seo_Precedence::FIELD_TITLE
301 : Metasync_Seo_Precedence::FIELD_DESCRIPTION;
302
303 $resolved = Metasync_Seo_Precedence::resolve($post_id, $field_const);
304
305 if ($resolved['value'] !== '') {
306 return array(
307 'value' => $resolved['value'],
308 'source' => self::display_source($post_id, $field, $resolved),
309 );
310 }
311
312 // Cross-plugin, legacy and OG keys — already limited to active plugins.
313 $meta_keys = ($field === 'title') ? self::third_party_title_keys() : self::third_party_desc_keys();
314
315 foreach ($meta_keys as $meta_key => $source) {
316 $value = get_post_meta($post_id, $meta_key, true);
317 if (!empty($value)) {
318 return array('value' => $value, 'source' => $source);
319 }
320 }
321
322 return array('value' => '', 'source' => '');
323 }
324
325 /**
326 * The badge label for a resolver result, if any.
327 *
328 * The imported tier is an internal fallback only — it reports as set
329 * without a label, the same as a value the customer owns. The persisted
330 * OTTO keys are the one case that needs care: the sync writes OTTO's text
331 * to both the native key and the OTTO staging key, and the native one sits
332 * higher in the chain, so comparing the two is what proves the value is
333 * OTTO's rather than something the customer typed into the old field. Only
334 * the persisted keys get that comparison — a customer value that happens
335 * to match OTTO's suggestion stays unbadged.
336 *
337 * @param int $post_id Post ID.
338 * @param string $field Either 'title' or 'description'.
339 * @param array $resolved Resolver result with 'key' and 'source'.
340 * @return string '' when no badge applies.
341 */
342 private static function display_source($post_id, $field, array $resolved)
343 {
344 $source = $resolved['source'];
345
346 if ($source === Metasync_Seo_Precedence::SOURCE_IMPORTED) {
347 return '';
348 }
349
350 if ($source === '') {
351 $persisted = ($field === 'title')
352 ? Metasync_Seo_Precedence::KEY_PERSISTED_OTTO_TITLE
353 : Metasync_Seo_Precedence::KEY_PERSISTED_OTTO_DESC;
354 $otto_key = ($field === 'title') ? '_metasync_otto_title' : '_metasync_otto_description';
355
356 if ($resolved['key'] === $persisted
357 && $resolved['value'] === get_post_meta($post_id, $otto_key, true)) {
358 $source = 'OTTO';
359 }
360 }
361
362 // White-label the OTTO source label at render time (const values stay literal).
363 if ($source === 'OTTO') {
364 $source = Metasync::get_whitelabel_otto_name();
365 }
366
367 return $source;
368 }
369
370 /**
371 * Check if a post has an OG image (MetaSync or featured image fallback).
372 *
373 * @param int $post_id Post ID.
374 * @return bool
375 */
376 public static function has_og_image($post_id)
377 {
378 $og_image = get_post_meta($post_id, '_metasync_og_image', true);
379 if (!empty($og_image)) {
380 return true;
381 }
382 return !empty(get_post_meta($post_id, '_thumbnail_id', true));
383 }
384
385 /**
386 * Check if a post has meaningful schema markup.
387 * Treats empty arrays/objects as "no schema".
388 *
389 * @param int $post_id Post ID.
390 * @return bool
391 */
392 public static function has_schema($post_id)
393 {
394 $schema = get_post_meta($post_id, 'metasync_schema_markup', true);
395 if (empty($schema)) {
396 return false;
397 }
398 if (is_string($schema)) {
399 $decoded = json_decode($schema, true);
400 return is_array($decoded) && !empty($decoded);
401 }
402 if (is_array($schema)) {
403 return !empty($schema);
404 }
405 return false;
406 }
407
408 /**
409 * Calculate alt text coverage for images in post content.
410 *
411 * @param string $content Post content HTML.
412 * @return array Array with 'total', 'with_alt', and 'percentage' keys.
413 */
414 public static function calculate_alt_text_coverage($content)
415 {
416 $result = array('total' => 0, 'with_alt' => 0, 'percentage' => null);
417
418 if (!preg_match_all('/<img[^>]*>/i', $content, $matches)) {
419 return $result;
420 }
421
422 $result['total'] = count($matches[0]);
423 foreach ($matches[0] as $img_tag) {
424 if (preg_match('/alt\s*=\s*["\']([^"\']+)["\']/i', $img_tag, $alt_match)) {
425 if (!empty(trim($alt_match[1]))) {
426 $result['with_alt']++;
427 }
428 }
429 }
430
431 $result['percentage'] = $result['total'] > 0
432 ? round(($result['with_alt'] / $result['total']) * 100)
433 : 100;
434
435 return $result;
436 }
437
438 /**
439 * Render the SEO Health dashboard page.
440 * Uses the 3-column sidebar layout (render_layout_open/close).
441 */
442 public function render_page()
443 {
444 require_once plugin_dir_path(__FILE__) . 'class-metasync-seo-health-list-table.php';
445
446 $stats = $this->get_summary_stats();
447 $list_table = new Metasync_SEO_Health_List_Table();
448 $list_table->prepare_items();
449
450 $nav = Metasync_Admin_Navigation::instance();
451 $nav->render_layout_open(
452 'SEO Health',
453 'seo_health',
454 __('Bird\'s-eye view of SEO completeness across all your content.', 'metasync')
455 );
456 ?>
457
458 <div class="metasync-seo-health-summary">
459 <?php
460 $stat_cards = array(
461 array('value' => $stats['total_posts'], 'label' => __('Total Posts', 'metasync'), 'type' => 'count'),
462 array('value' => $stats['pct_seo_title'], 'label' => __('SEO Title Set', 'metasync'), 'type' => 'percent'),
463 array('value' => $stats['pct_meta_description'], 'label' => __('Meta Description Set', 'metasync'), 'type' => 'percent'),
464 array('value' => $stats['pct_schema'], 'label' => __('Schema Markup', 'metasync'), 'type' => 'percent'),
465 array('value' => $stats['pct_og_image'], 'label' => __('OG Image Set', 'metasync'), 'type' => 'percent'),
466 );
467 foreach ($stat_cards as $card):
468 $display = ($card['type'] === 'percent') ? $card['value'] . '%' : $card['value'];
469 $color_class = 'stat-neutral';
470 if ($card['type'] === 'percent') {
471 if ($card['value'] >= 80) {
472 $color_class = 'stat-success';
473 } elseif ($card['value'] >= 50) {
474 $color_class = 'stat-warning';
475 } else {
476 $color_class = 'stat-error';
477 }
478 }
479 ?>
480 <div class="metasync-health-stat-card">
481 <div class="stat-value <?php echo esc_attr($color_class); ?>"><?php echo esc_html($display); ?></div>
482 <div class="stat-label"><?php echo esc_html($card['label']); ?></div>
483 </div>
484 <?php endforeach; ?>
485 </div>
486
487 <form method="get">
488 <input type="hidden" name="page" value="<?php echo esc_attr($_GET['page'] ?? ''); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page slug, escaped into a form field ?>" />
489
490 <div class="metasync-health-toolbar">
491 <a href="<?php echo esc_url(wp_nonce_url(add_query_arg('export', 'csv'), 'metasync_seo_health_export')); ?>" class="button button-secondary">
492 <?php esc_html_e('Export CSV', 'metasync'); ?>
493 </a>
494 <?php $list_table->search_box(__('Search Posts', 'metasync'), 'seo-health-search'); ?>
495 </div>
496
497 <?php $list_table->display(); ?>
498 </form>
499
500 <?php
501 $nav->render_layout_close();
502 }
503
504 /**
505 * Get summary statistics, cached in a transient.
506 * Uses WP_Query with meta_query for counting (no raw SQL).
507 * Primes the meta cache in one batch to avoid N+1 lookups.
508 *
509 * @return array
510 */
511 public function get_summary_stats()
512 {
513 $stats = get_transient(self::get_stats_transient_key());
514
515 if ($stats !== false) {
516 return $stats;
517 }
518
519 $post_types = self::get_supported_post_types();
520 $batch_size = 500;
521 $last_id = 0;
522
523 $total = 0;
524 $with_title = 0;
525 $with_desc = 0;
526 $with_schema = 0;
527 $with_og_image = 0;
528
529 add_filter('posts_where', 'metasync_filter_exclude_custom_pages_where', 10, 2);
530 add_filter('posts_where', array('Metasync_SEO_Inventory_Builder', 'filter_where_id_gt'), 10, 2);
531
532 try {
533 while (true) {
534 $args = array(
535 'post_type' => $post_types,
536 'post_status' => array('publish', 'draft'),
537 'posts_per_page' => $batch_size,
538 'orderby' => 'ID',
539 'order' => 'ASC',
540 'fields' => 'ids',
541 'no_found_rows' => true,
542 'update_post_meta_cache' => false,
543 'update_post_term_cache' => false,
544 'metasync_exclude_custom_pages' => true,
545 );
546
547 if ($last_id > 0) {
548 $args['where_id_gt'] = $last_id;
549 }
550
551 $query = new WP_Query($args);
552 $post_ids = $query->posts;
553
554 if (empty($post_ids)) {
555 break;
556 }
557
558 // Prime meta cache for this chunk of IDs
559 update_meta_cache('post', $post_ids);
560
561 foreach ($post_ids as $post_id) {
562 $total++;
563
564 if (!empty(self::get_seo_meta_with_fallback($post_id, 'title')['value'])) {
565 $with_title++;
566 }
567
568 if (!empty(self::get_seo_meta_with_fallback($post_id, 'description')['value'])) {
569 $with_desc++;
570 }
571
572 if (self::has_schema($post_id)) {
573 $with_schema++;
574 }
575
576 if (self::has_og_image($post_id)) {
577 $with_og_image++;
578 }
579
580 $last_id = (int) $post_id;
581 }
582
583 if (count($post_ids) < $batch_size) {
584 break;
585 }
586 }
587 } finally {
588 remove_filter('posts_where', 'metasync_filter_exclude_custom_pages_where', 10);
589 remove_filter('posts_where', array('Metasync_SEO_Inventory_Builder', 'filter_where_id_gt'), 10);
590 }
591
592 if ($total === 0) {
593 $stats = array(
594 'total_posts' => 0,
595 'pct_seo_title' => 0,
596 'pct_meta_description' => 0,
597 'pct_schema' => 0,
598 'pct_og_image' => 0,
599 );
600 set_transient(self::get_stats_transient_key(), $stats, 3600);
601 return $stats;
602 }
603
604 $stats = array(
605 'total_posts' => $total,
606 'pct_seo_title' => (int) round(($with_title / $total) * 100),
607 'pct_meta_description' => (int) round(($with_desc / $total) * 100),
608 'pct_schema' => (int) round(($with_schema / $total) * 100),
609 'pct_og_image' => (int) round(($with_og_image / $total) * 100),
610 );
611
612 set_transient(self::get_stats_transient_key(), $stats, 3600);
613
614 return $stats;
615 }
616
617 /**
618 * Invalidate the summary stats transient on post save.
619 */
620 public function invalidate_cache()
621 {
622 self::invalidate_stats();
623 }
624
625 /**
626 * Delete the summary stats transient.
627 *
628 * Public and static so writers that update SEO meta outside of save_post
629 * (OTTO crawl-notify job, MCP tools) can keep the dashboard aggregates in
630 * step with the table, which reads live post meta.
631 */
632 public static function invalidate_stats()
633 {
634 delete_transient(self::get_stats_transient_key());
635 }
636
637 /**
638 * Export filtered results as CSV.
639 *
640 * The columns mirror the table: value and status, no source. The export is
641 * the table's own download, so a Source column here would reintroduce on
642 * paper exactly the provenance the table stopped showing.
643 */
644 public function export_csv()
645 {
646 require_once plugin_dir_path(__FILE__) . 'class-metasync-seo-health-list-table.php';
647
648 header('Content-Type: text/csv; charset=utf-8');
649 header('Content-Disposition: attachment; filename=seo-health-export.csv');
650
651 $output = fopen('php://output', 'w');
652
653 fputcsv($output, array(
654 'Post ID', 'Title', 'Post Type', 'Status',
655 'SEO Title',
656 'Meta Description',
657 'Has Schema', 'Has OG Image',
658 'Alt Text Coverage %', 'Last Modified',
659 ));
660
661 $args = array(
662 'post_type' => self::get_supported_post_types(),
663 'post_status' => array('publish', 'draft'),
664 'posts_per_page' => 500,
665 'orderby' => 'ID',
666 'order' => 'ASC',
667 'no_found_rows' => true,
668 'update_post_meta_cache' => true,
669 'update_post_term_cache' => false,
670 'metasync_exclude_custom_pages' => true,
671 );
672
673 $post_type_filter = isset($_GET['post_type_filter']) ? sanitize_text_field(wp_unslash($_GET['post_type_filter'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- caller verifies a nonce and capability before the export
674 if (!empty($post_type_filter) && in_array($post_type_filter, self::get_supported_post_types(), true)) {
675 $args['post_type'] = $post_type_filter;
676 }
677
678 $status_filter = isset($_GET['status_filter']) ? sanitize_text_field(wp_unslash($_GET['status_filter'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- caller verifies a nonce and capability before the export
679 if (!empty($status_filter) && in_array($status_filter, array('publish', 'draft'), true)) {
680 $args['post_status'] = $status_filter;
681 }
682
683 $search = isset($_GET['s']) ? sanitize_text_field(wp_unslash($_GET['s'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- caller verifies a nonce and capability before the export
684 if (!empty($search)) {
685 $args['s'] = $search;
686 }
687
688 $batch_size = 500;
689 $last_id = 0;
690
691 add_filter('posts_where', 'metasync_filter_exclude_custom_pages_where', 10, 2);
692 add_filter('posts_where', array('Metasync_SEO_Inventory_Builder', 'filter_where_id_gt'), 10, 2);
693
694 try {
695 while (true) {
696 if ($last_id > 0) {
697 $args['where_id_gt'] = $last_id;
698 }
699
700 $query = new WP_Query($args);
701 $posts = $query->posts;
702
703 if (empty($posts)) {
704 break;
705 }
706
707 foreach ($posts as $post) {
708 $title_result = self::get_seo_meta_with_fallback($post->ID, 'title');
709 $desc_result = self::get_seo_meta_with_fallback($post->ID, 'description');
710 $alt = self::calculate_alt_text_coverage($post->post_content);
711
712 fputcsv($output, array(
713 $post->ID,
714 $post->post_title,
715 $post->post_type,
716 $post->post_status,
717 $title_result['value'],
718 $desc_result['value'],
719 self::has_schema($post->ID) ? 'Yes' : 'No',
720 self::has_og_image($post->ID) ? 'Yes' : 'No',
721 $alt['percentage'] !== null ? $alt['percentage'] : 'N/A',
722 get_the_modified_date('Y-m-d', $post),
723 ));
724
725 $last_id = (int) $post->ID;
726 }
727
728 if (count($posts) < $batch_size) {
729 break;
730 }
731 }
732 } finally {
733 remove_filter('posts_where', 'metasync_filter_exclude_custom_pages_where', 10);
734 remove_filter('posts_where', array('Metasync_SEO_Inventory_Builder', 'filter_where_id_gt'), 10);
735 }
736
737 fclose($output); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- operates on a native stream handle
738 exit;
739 }
740 }
741