PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.22
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.22
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 2.5.23 All 138 releases
metasync / includes / class-metasync-seo-inventory-builder.php

class-metasync-seo-inventory-builder.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.22, at includes/class-metasync-seo-inventory-builder.php

279 lines 10.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * SEO Inventory Builder
4 *
5 * Shared builder that fetches all published posts/pages with their SEO metadata
6 * in bulk using cursor-based pagination. Used by both the REST endpoint and
7 * the MCP tool so the logic lives in one place.
8 *
9 * @package MetaSync
10 * @subpackage Includes
11 */
12
13 if (!defined('ABSPATH')) {
14 exit;
15 }
16
17 class Metasync_SEO_Inventory_Builder {
18
19 /**
20 * Meta keys we read for every post (MCP field set — matches).
21 */
22 const SEO_META_KEYS = [
23 'meta_title' => '_metasync_metatitle',
24 'meta_description' => '_metasync_metadesc',
25 'focus_keyword' => '_metasync_focus_keyword',
26 'robots' => '_metasync_robots_index',
27 'canonical_url' => '_metasync_canonical_url',
28 'og_title' => '_metasync_og_title',
29 'og_description' => '_metasync_og_description',
30 'og_image' => '_metasync_og_image',
31 'og_type' => '_metasync_og_type',
32 'twitter_title' => '_metasync_twitter_title',
33 'twitter_description' => '_metasync_twitter_description',
34 ];
35
36 const META_TITLE_MAX_LENGTH = 60;
37 const META_DESCRIPTION_MAX_LENGTH = 160;
38 const DEFAULT_LIMIT = 200;
39 const MAX_LIMIT = 500;
40
41 /**
42 * Build the inventory response.
43 *
44 * @param array $args {
45 * @type string $post_type 'post', 'page', or 'any'. Default 'any'.
46 * @type string $post_status 'publish', 'draft', etc. Default 'publish'.
47 * @type int $limit Items per page (1–500). Default 200.
48 * @type int $cursor Post ID to start after (keyset pagination). Default 0.
49 * @type bool $include_issues Whether to add pre-computed issue flags. Default true.
50 * @type string $modified_after ISO 8601 date — only return posts modified after this.
51 * }
52 * @return array Inventory payload with items, total, next_cursor, has_more.
53 */
54 public static function build(array $args = []): array {
55 $post_type = isset($args['post_type']) ? sanitize_text_field($args['post_type']) : 'any';
56 $post_status = isset($args['post_status']) ? sanitize_text_field($args['post_status']) : 'publish';
57 $limit = isset($args['limit']) ? absint($args['limit']) : self::DEFAULT_LIMIT;
58 $cursor = isset($args['cursor']) ? absint($args['cursor']) : 0;
59 $include_issues = isset($args['include_issues']) ? (bool) $args['include_issues'] : true;
60 $modified_after = isset($args['modified_after']) ? sanitize_text_field($args['modified_after']) : '';
61
62 $limit = max(1, min($limit, self::MAX_LIMIT));
63
64 $query_args = [
65 'post_type' => $post_type === 'any' ? ['post', 'page'] : $post_type,
66 'post_status' => $post_status,
67 'posts_per_page' => $limit + 1, // fetch one extra to detect has_more
68 'orderby' => 'ID',
69 'order' => 'ASC',
70 'no_found_rows' => true, // skip SQL_CALC_FOUND_ROWS for performance
71 'update_post_meta_cache' => true,
72 'update_post_term_cache' => false,
73 ];
74
75 // Keyset pagination: only posts with ID > cursor
76 if ($cursor > 0) {
77 $query_args['where_id_gt'] = $cursor; // handled by filter below
78 add_filter('posts_where', [__CLASS__, 'filter_where_id_gt'], 10, 2);
79 }
80
81 // Modified-after filter
82 if (!empty($modified_after)) {
83 $query_args['date_query'] = [
84 [
85 'after' => $modified_after,
86 'column' => 'post_modified_gmt',
87 'inclusive' => false,
88 ],
89 ];
90 }
91
92 // Exclude custom/LPS pages — their SEO is self-managed, so the platform
93 // must not treat them as "missing SEO".
94 $query_args['meta_query'] = [metasync_get_custom_page_exclusion_meta_query()];
95
96 $query = new WP_Query($query_args);
97
98 // Remove the filter immediately so it doesn't affect other queries
99 remove_filter('posts_where', [__CLASS__, 'filter_where_id_gt'], 10);
100
101 $posts = $query->posts;
102 $has_more = count($posts) > $limit;
103
104 if ($has_more) {
105 array_pop($posts); // remove the extra sentinel post
106 }
107
108 // Meta cache is already primed by WP_Query (update_post_meta_cache => true)
109
110 $items = [];
111 $next_cursor = 0;
112
113 foreach ($posts as $post) {
114 $item = self::build_item($post, $include_issues);
115 $items[] = $item;
116 $next_cursor = $post->ID;
117 }
118
119 // Total count (separate lightweight query)
120 $total = self::get_total_count($post_type, $post_status, $modified_after);
121
122 return [
123 'items' => $items,
124 'total' => $total,
125 'next_cursor' => $has_more ? $next_cursor : null,
126 'has_more' => $has_more,
127 ];
128 }
129
130 /**
131 * Build a single post's inventory item.
132 *
133 * @param WP_Post $post
134 * @param bool $include_issues
135 * @return array
136 */
137 private static function build_item(WP_Post $post, bool $include_issues): array {
138 $post_id = $post->ID;
139
140 // Basic info
141 // Page-builder content (Divi [et_pb_*], Elementor, WPBakery) is stored
142 // as shortcodes whose handlers are not registered during a REST inventory request.
143 // Render what we can, then strip registered + leftover shortcode-style tags so raw
144 // builder markup does not leak into the content/word-count sent to SearchAtlas.
145 $content_text = do_shortcode($post->post_content);
146 $content_text = strip_shortcodes($content_text);
147 $content_text = preg_replace('/\[\/?[a-zA-Z][^\]]*\]/', '', $content_text);
148 $content_text = wp_strip_all_tags($content_text);
149 $word_count = str_word_count($content_text);
150
151 $item = [
152 'post_id' => $post_id,
153 'post_type' => $post->post_type,
154 'post_status' => $post->post_status,
155 'url' => get_permalink($post_id),
156 'slug' => $post->post_name,
157 'title' => $post->post_title,
158 'last_modified' => get_post_modified_time('c', true, $post_id),
159 'word_count' => $word_count,
160 ];
161
162 // SEO sub-object — cast to string to guard against array meta
163 // (another plugin or manual DB edit could store non-string values,
164 // which would crash mb_strlen in compute_issues on PHP 8+)
165 $seo = [];
166 foreach (self::SEO_META_KEYS as $field => $meta_key) {
167 $value = get_post_meta($post_id, $meta_key, true);
168 $seo[$field] = is_string($value) ? $value : '';
169 }
170
171 // Schema types — handle both storage formats:
172 // 1. User/MCP format: ['types' => [['type' => 'article', ...], ...]]
173 // 2. OTTO format: ['otto_jsonld' => [...]]
174 $schema_data = get_post_meta($post_id, 'metasync_schema_markup', true);
175 $schema_types = [];
176 if (!empty($schema_data) && is_array($schema_data)) {
177 // Extract user-configured type names from 'types' array
178 if (isset($schema_data['types']) && is_array($schema_data['types'])) {
179 foreach ($schema_data['types'] as $type_entry) {
180 if (is_array($type_entry) && isset($type_entry['type'])) {
181 $schema_types[] = $type_entry['type'];
182 }
183 }
184 }
185 // Include OTTO/other top-level schema keys (e.g. 'otto_jsonld')
186 foreach (array_keys($schema_data) as $key) {
187 if (!in_array($key, ['enabled', 'types'], true)) {
188 $schema_types[] = $key;
189 }
190 }
191 }
192 $seo['schema_types'] = $schema_types;
193
194 $item['seo'] = $seo;
195
196 // Pre-computed issue flags
197 if ($include_issues) {
198 $item['issues'] = self::compute_issues($seo);
199 }
200
201 return $item;
202 }
203
204 /**
205 * Compute SEO issue flags for a post.
206 *
207 * @param array $seo The SEO sub-object.
208 * @return array Boolean issue flags.
209 */
210 private static function compute_issues(array $seo): array {
211 $meta_title = $seo['meta_title'] ?? '';
212 $meta_desc = $seo['meta_description'] ?? '';
213
214 return [
215 'missing_meta_title' => empty($meta_title),
216 'missing_meta_description' => empty($meta_desc),
217 'meta_title_too_long' => mb_strlen($meta_title) > self::META_TITLE_MAX_LENGTH,
218 'meta_description_too_long' => mb_strlen($meta_desc) > self::META_DESCRIPTION_MAX_LENGTH,
219 'is_noindex' => ($seo['robots'] ?? '') === 'noindex',
220 'missing_og_image' => empty($seo['og_image'] ?? ''),
221 'missing_schema' => empty($seo['schema_types']),
222 'missing_focus_keyword' => empty($seo['focus_keyword'] ?? ''),
223 ];
224 }
225
226 /**
227 * Get total count of matching posts (lightweight).
228 *
229 * @param string $post_type
230 * @param string $post_status
231 * @param string $modified_after
232 * @return int
233 */
234 private static function get_total_count(string $post_type, string $post_status, string $modified_after): int {
235 $args = [
236 'post_type' => $post_type === 'any' ? ['post', 'page'] : $post_type,
237 'post_status' => $post_status,
238 'posts_per_page' => 1,
239 'fields' => 'ids',
240 'no_found_rows' => false, // we need found_rows for total
241 'update_post_meta_cache' => false,
242 'update_post_term_cache' => false,
243 // Exclude custom/LPS pages so the total matches the excluded item list
244 // in build() — otherwise the platform sees a count larger than the
245 // number of items it can page through.
246 'meta_query' => [metasync_get_custom_page_exclusion_meta_query()],
247 ];
248
249 if (!empty($modified_after)) {
250 $args['date_query'] = [
251 [
252 'after' => $modified_after,
253 'column' => 'post_modified_gmt',
254 'inclusive' => false,
255 ],
256 ];
257 }
258
259 $query = new WP_Query($args);
260 return (int) $query->found_posts;
261 }
262
263 /**
264 * WP_Query filter: WHERE ID > cursor for keyset pagination.
265 *
266 * @param string $where
267 * @param WP_Query $query
268 * @return string
269 */
270 public static function filter_where_id_gt(string $where, WP_Query $query): string {
271 $cursor = isset($query->query_vars['where_id_gt']) ? absint($query->query_vars['where_id_gt']) : 0;
272 if ($cursor > 0) {
273 global $wpdb;
274 $where .= $wpdb->prepare(" AND {$wpdb->posts}.ID > %d", $cursor);
275 }
276 return $where;
277 }
278 }
279