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

264 lines 9.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 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 $content_text = wp_strip_all_tags($post->post_content);
138 $word_count = str_word_count($content_text);
139
140 $item = [
141 'post_id' => $post_id,
142 'post_type' => $post->post_type,
143 'post_status' => $post->post_status,
144 'url' => get_permalink($post_id),
145 'slug' => $post->post_name,
146 'title' => $post->post_title,
147 'last_modified' => get_post_modified_time('c', true, $post_id),
148 'word_count' => $word_count,
149 ];
150
151 // SEO sub-object — cast to string to guard against array meta
152 // (another plugin or manual DB edit could store non-string values,
153 // which would crash mb_strlen in compute_issues on PHP 8+)
154 $seo = [];
155 foreach (self::SEO_META_KEYS as $field => $meta_key) {
156 $value = get_post_meta($post_id, $meta_key, true);
157 $seo[$field] = is_string($value) ? $value : '';
158 }
159
160 // Schema types — handle both storage formats:
161 // 1. User/MCP format: ['types' => [['type' => 'article', ...], ...]]
162 // 2. OTTO format: ['otto_jsonld' => [...]]
163 $schema_data = get_post_meta($post_id, 'metasync_schema_markup', true);
164 $schema_types = [];
165 if (!empty($schema_data) && is_array($schema_data)) {
166 // Extract user-configured type names from 'types' array
167 if (isset($schema_data['types']) && is_array($schema_data['types'])) {
168 foreach ($schema_data['types'] as $type_entry) {
169 if (is_array($type_entry) && isset($type_entry['type'])) {
170 $schema_types[] = $type_entry['type'];
171 }
172 }
173 }
174 // Include OTTO/other top-level schema keys (e.g. 'otto_jsonld')
175 foreach (array_keys($schema_data) as $key) {
176 if (!in_array($key, ['enabled', 'types'], true)) {
177 $schema_types[] = $key;
178 }
179 }
180 }
181 $seo['schema_types'] = $schema_types;
182
183 $item['seo'] = $seo;
184
185 // Pre-computed issue flags
186 if ($include_issues) {
187 $item['issues'] = self::compute_issues($seo);
188 }
189
190 return $item;
191 }
192
193 /**
194 * Compute SEO issue flags for a post.
195 *
196 * @param array $seo The SEO sub-object.
197 * @return array Boolean issue flags.
198 */
199 private static function compute_issues(array $seo): array {
200 $meta_title = $seo['meta_title'] ?? '';
201 $meta_desc = $seo['meta_description'] ?? '';
202
203 return [
204 'missing_meta_title' => empty($meta_title),
205 'missing_meta_description' => empty($meta_desc),
206 'meta_title_too_long' => mb_strlen($meta_title) > self::META_TITLE_MAX_LENGTH,
207 'meta_description_too_long' => mb_strlen($meta_desc) > self::META_DESCRIPTION_MAX_LENGTH,
208 'is_noindex' => ($seo['robots'] ?? '') === 'noindex',
209 'missing_og_image' => empty($seo['og_image'] ?? ''),
210 'missing_schema' => empty($seo['schema_types']),
211 'missing_focus_keyword' => empty($seo['focus_keyword'] ?? ''),
212 ];
213 }
214
215 /**
216 * Get total count of matching posts (lightweight).
217 *
218 * @param string $post_type
219 * @param string $post_status
220 * @param string $modified_after
221 * @return int
222 */
223 private static function get_total_count(string $post_type, string $post_status, string $modified_after): int {
224 $args = [
225 'post_type' => $post_type === 'any' ? ['post', 'page'] : $post_type,
226 'post_status' => $post_status,
227 'posts_per_page' => 1,
228 'fields' => 'ids',
229 'no_found_rows' => false, // we need found_rows for total
230 'update_post_meta_cache' => false,
231 'update_post_term_cache' => false,
232 ];
233
234 if (!empty($modified_after)) {
235 $args['date_query'] = [
236 [
237 'after' => $modified_after,
238 'column' => 'post_modified_gmt',
239 'inclusive' => false,
240 ],
241 ];
242 }
243
244 $query = new WP_Query($args);
245 return (int) $query->found_posts;
246 }
247
248 /**
249 * WP_Query filter: WHERE ID > cursor for keyset pagination.
250 *
251 * @param string $where
252 * @param WP_Query $query
253 * @return string
254 */
255 public static function filter_where_id_gt(string $where, WP_Query $query): string {
256 $cursor = isset($query->query_vars['where_id_gt']) ? absint($query->query_vars['where_id_gt']) : 0;
257 if ($cursor > 0) {
258 global $wpdb;
259 $where .= $wpdb->prepare(" AND {$wpdb->posts}.ID > %d", $cursor);
260 }
261 return $where;
262 }
263 }
264