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

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