PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.0
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.0
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 / includes / class-metasync-seo-inventory-builder.php

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

291 lines 11.4 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['metasync_exclude_custom_pages'] = true;
95 add_filter('posts_where', 'metasync_filter_exclude_custom_pages_where', 10, 2);
96
97 try {
98 $query = new WP_Query($query_args);
99 } finally {
100 // Remove filters immediately so they don't affect other queries
101 if ($cursor > 0) {
102 remove_filter('posts_where', [__CLASS__, 'filter_where_id_gt'], 10);
103 }
104 remove_filter('posts_where', 'metasync_filter_exclude_custom_pages_where', 10);
105 }
106
107 $posts = $query->posts;
108 $has_more = count($posts) > $limit;
109
110 if ($has_more) {
111 array_pop($posts); // remove the extra sentinel post
112 }
113
114 // Meta cache is already primed by WP_Query (update_post_meta_cache => true)
115
116 $items = [];
117 $next_cursor = 0;
118
119 foreach ($posts as $post) {
120 $item = self::build_item($post, $include_issues);
121 $items[] = $item;
122 $next_cursor = $post->ID;
123 }
124
125 // Total count (separate lightweight query)
126 $total = self::get_total_count($post_type, $post_status, $modified_after);
127
128 return [
129 'items' => $items,
130 'total' => $total,
131 'next_cursor' => $has_more ? $next_cursor : null,
132 'has_more' => $has_more,
133 ];
134 }
135
136 /**
137 * Build a single post's inventory item.
138 *
139 * @param WP_Post $post
140 * @param bool $include_issues
141 * @return array
142 */
143 private static function build_item(WP_Post $post, bool $include_issues): array {
144 $post_id = $post->ID;
145
146 // Basic info
147 // Page-builder content (Divi [et_pb_*], Elementor, WPBakery) is stored
148 // as shortcodes whose handlers are not registered during a REST inventory request.
149 // Render what we can, then strip registered + leftover shortcode-style tags so raw
150 // builder markup does not leak into the content/word-count sent to SearchAtlas.
151 $content_text = do_shortcode($post->post_content);
152 $content_text = strip_shortcodes($content_text);
153 $content_text = preg_replace('/\[\/?[a-zA-Z][^\]]*\]/', '', $content_text);
154 $content_text = wp_strip_all_tags($content_text);
155 $word_count = str_word_count($content_text);
156
157 $item = [
158 'post_id' => $post_id,
159 'post_type' => $post->post_type,
160 'post_status' => $post->post_status,
161 'url' => get_permalink($post_id),
162 'slug' => $post->post_name,
163 'title' => $post->post_title,
164 'last_modified' => get_post_modified_time('c', true, $post_id),
165 'word_count' => $word_count,
166 ];
167
168 // SEO sub-object — cast to string to guard against array meta
169 // (another plugin or manual DB edit could store non-string values,
170 // which would crash mb_strlen in compute_issues on PHP 8+)
171 $seo = [];
172 foreach (self::SEO_META_KEYS as $field => $meta_key) {
173 $value = get_post_meta($post_id, $meta_key, true);
174 $seo[$field] = is_string($value) ? $value : '';
175 }
176
177 // Schema types — handle both storage formats:
178 // 1. User/MCP format: ['types' => [['type' => 'article', ...], ...]]
179 // 2. OTTO format: ['otto_jsonld' => [...]]
180 $schema_data = get_post_meta($post_id, 'metasync_schema_markup', true);
181 $schema_types = [];
182 if (!empty($schema_data) && is_array($schema_data)) {
183 // Extract user-configured type names from 'types' array
184 if (isset($schema_data['types']) && is_array($schema_data['types'])) {
185 foreach ($schema_data['types'] as $type_entry) {
186 if (is_array($type_entry) && isset($type_entry['type'])) {
187 $schema_types[] = $type_entry['type'];
188 }
189 }
190 }
191 // Include OTTO/other top-level schema keys (e.g. 'otto_jsonld')
192 foreach (array_keys($schema_data) as $key) {
193 if (!in_array($key, ['enabled', 'types'], true)) {
194 $schema_types[] = $key;
195 }
196 }
197 }
198 $seo['schema_types'] = $schema_types;
199
200 $item['seo'] = $seo;
201
202 // Pre-computed issue flags
203 if ($include_issues) {
204 $item['issues'] = self::compute_issues($seo);
205 }
206
207 return $item;
208 }
209
210 /**
211 * Compute SEO issue flags for a post.
212 *
213 * @param array $seo The SEO sub-object.
214 * @return array Boolean issue flags.
215 */
216 private static function compute_issues(array $seo): array {
217 $meta_title = $seo['meta_title'] ?? '';
218 $meta_desc = $seo['meta_description'] ?? '';
219
220 return [
221 'missing_meta_title' => empty($meta_title),
222 'missing_meta_description' => empty($meta_desc),
223 'meta_title_too_long' => mb_strlen($meta_title) > self::META_TITLE_MAX_LENGTH,
224 'meta_description_too_long' => mb_strlen($meta_desc) > self::META_DESCRIPTION_MAX_LENGTH,
225 'is_noindex' => ($seo['robots'] ?? '') === 'noindex',
226 'missing_og_image' => empty($seo['og_image'] ?? ''),
227 'missing_schema' => empty($seo['schema_types']),
228 'missing_focus_keyword' => empty($seo['focus_keyword'] ?? ''),
229 ];
230 }
231
232 /**
233 * Get total count of matching posts (lightweight).
234 *
235 * @param string $post_type
236 * @param string $post_status
237 * @param string $modified_after
238 * @return int
239 */
240 private static function get_total_count(string $post_type, string $post_status, string $modified_after): int {
241 $args = [
242 'post_type' => $post_type === 'any' ? ['post', 'page'] : $post_type,
243 'post_status' => $post_status,
244 'posts_per_page' => 1,
245 'fields' => 'ids',
246 'no_found_rows' => false, // we need found_rows for total
247 'update_post_meta_cache' => false,
248 'update_post_term_cache' => false,
249 // Exclude custom/LPS pages so the total matches the excluded item list
250 // in build() — otherwise the platform sees a count larger than the
251 // number of items it can page through.
252 'metasync_exclude_custom_pages' => true,
253 ];
254
255 if (!empty($modified_after)) {
256 $args['date_query'] = [
257 [
258 'after' => $modified_after,
259 'column' => 'post_modified_gmt',
260 'inclusive' => false,
261 ],
262 ];
263 }
264
265 add_filter('posts_where', 'metasync_filter_exclude_custom_pages_where', 10, 2);
266 try {
267 $query = new WP_Query($args);
268 } finally {
269 remove_filter('posts_where', 'metasync_filter_exclude_custom_pages_where', 10);
270 }
271
272 return (int) $query->found_posts;
273 }
274
275 /**
276 * WP_Query filter: WHERE ID > cursor for keyset pagination.
277 *
278 * @param string $where
279 * @param WP_Query $query
280 * @return string
281 */
282 public static function filter_where_id_gt(string $where, WP_Query $query): string {
283 $cursor = isset($query->query_vars['where_id_gt']) ? absint($query->query_vars['where_id_gt']) : 0;
284 if ($cursor > 0) {
285 global $wpdb;
286 $where .= $wpdb->prepare(" AND {$wpdb->posts}.ID > %d", $cursor);
287 }
288 return $where;
289 }
290 }
291