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-link-suggestions.php

class-metasync-link-suggestions.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.2, at admin/class-metasync-link-suggestions.php

401 lines 14.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MetaSync Internal Link Suggestions
4 *
5 * Provides a REST API endpoint that returns relevant internal link suggestions
6 * based on content similarity for the Gutenberg editor sidebar.
7 *
8 * @package Metasync
9 * @subpackage Metasync/admin
10 * @since 2.8.0
11 */
12
13 if (!defined('ABSPATH')) {
14 exit;
15 }
16
17 class Metasync_Link_Suggestions {
18
19 /**
20 * Common stop words to filter out from bigram matching
21 */
22 private static $stop_words = array(
23 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
24 'of', 'with', 'by', 'from', 'is', 'it', 'as', 'be', 'was', 'are',
25 'been', 'has', 'have', 'had', 'do', 'does', 'did', 'will', 'would',
26 'could', 'should', 'may', 'might', 'can', 'this', 'that', 'these',
27 'those', 'not', 'no', 'so', 'if', 'how', 'what', 'when', 'where',
28 'who', 'which', 'why', 'all', 'each', 'every', 'both', 'few', 'more',
29 'most', 'other', 'some', 'such', 'than', 'too', 'very', 'just', 'about',
30 'above', 'after', 'again', 'also', 'any', 'because', 'before', 'between',
31 'into', 'its', 'our', 'out', 'own', 'same', 'she', 'he', 'her', 'his',
32 'him', 'my', 'your', 'they', 'them', 'their', 'we', 'you', 'up', 'get',
33 );
34
35 /**
36 * Constructor
37 */
38 public function __construct() {
39 $this->init();
40 }
41
42 /**
43 * Initialize hooks
44 */
45 private function init() {
46 add_action('rest_api_init', array($this, 'register_rest_routes'));
47 add_action('save_post', array($this, 'invalidate_cache'), 10, 2);
48 }
49
50 /**
51 * Register REST API routes
52 */
53 public function register_rest_routes() {
54 register_rest_route('metasync/v1', '/link-suggestions', array(
55 'methods' => 'GET',
56 'callback' => array($this, 'get_suggestions'),
57 'permission_callback' => array($this, 'permission_check'),
58 'args' => array(
59 'post_id' => array(
60 'required' => true,
61 'type' => 'integer',
62 'sanitize_callback' => 'absint',
63 ),
64 'limit' => array(
65 'default' => 10,
66 'type' => 'integer',
67 'sanitize_callback' => 'absint',
68 ),
69 ),
70 ));
71 }
72
73 /**
74 * Permission callback - requires edit_posts capability
75 *
76 * @return bool
77 */
78 public function permission_check() {
79 return current_user_can('edit_posts');
80 }
81
82 /**
83 * Check if a bigram contains only stop words
84 *
85 * @param string $bigram Two-word phrase
86 * @return bool True if the bigram is too generic
87 */
88 private function is_generic_bigram($bigram) {
89 $words = preg_split('/\s+/', strtolower(trim($bigram)));
90 if (count($words) < 2) {
91 return true;
92 }
93 // Generic if BOTH words are stop words
94 $word1_is_stop = in_array($words[0], self::$stop_words, true);
95 $word2_is_stop = in_array($words[1], self::$stop_words, true);
96 return ($word1_is_stop && $word2_is_stop);
97 }
98
99 /**
100 * Calculate relevance score for a suggestion
101 *
102 * @param string $match_type Type of match (full_title, focus_keyword, bigram, tag)
103 * @param string $matched_phrase The phrase that matched
104 * @param string $candidate_title The candidate post title
105 * @return float Score between 0 and 1
106 */
107 private function calculate_relevance_score($match_type, $matched_phrase, $candidate_title) {
108 switch ($match_type) {
109 case 'full_title':
110 return 1.0;
111 case 'focus_keyword':
112 return 0.9;
113 case 'tag':
114 // Longer tag matches are more relevant
115 $len = strlen($matched_phrase);
116 return $len > 10 ? 0.7 : 0.6;
117 case 'bigram':
118 // Bigrams from longer titles that are more specific score higher
119 $title_word_count = str_word_count($candidate_title);
120 if ($title_word_count <= 3) {
121 return 0.5; // Short title = bigram covers most of it
122 }
123 return 0.4;
124 default:
125 return 0.3;
126 }
127 }
128
129 /**
130 * Get link suggestions for a post
131 *
132 * @param WP_REST_Request $request
133 * @return WP_REST_Response
134 */
135 public function get_suggestions($request) {
136 $post_id = intval($request->get_param('post_id'));
137 $limit = min(intval($request->get_param('limit')), 10);
138
139 if ($limit < 1) {
140 $limit = 10;
141 }
142
143 // Check transient cache
144 $cache_key = 'metasync_link_suggestions_' . $post_id;
145 $cached = get_transient($cache_key);
146 if ($cached !== false) {
147 return new WP_REST_Response($cached, 200);
148 }
149
150 // Verify the user can read this specific post (prevents IDOR)
151 if (!current_user_can('read_post', $post_id)) {
152 return new WP_REST_Response(array(
153 'suggestions' => array(),
154 'yoast_premium_active' => class_exists('WPSEO_Premium'),
155 'rankmath_active' => defined('RANK_MATH_VERSION'),
156 ), 200);
157 }
158
159 $post = get_post($post_id);
160 if (!$post) {
161 return new WP_REST_Response(array(
162 'suggestions' => array(),
163 'yoast_premium_active' => class_exists('WPSEO_Premium'),
164 'rankmath_active' => defined('RANK_MATH_VERSION'),
165 ), 200);
166 }
167
168 // Custom/LPS pages carry self-managed SEO in their own HTML bundle;
169 // bigram-scanning their raw HTML produces noise, so skip them entirely.
170 if (metasync_is_custom_or_lps_page($post_id)) {
171 return new WP_REST_Response(array(
172 'suggestions' => array(),
173 'yoast_premium_active' => class_exists('WPSEO_Premium'),
174 'rankmath_active' => defined('RANK_MATH_VERSION'),
175 ), 200);
176 }
177
178 // Strip block comments and HTML tags to get clean text for matching
179 $content = $post->post_content;
180 $content_text = wp_strip_all_tags(strip_shortcodes($content));
181
182 // Build list of already-linked URLs
183 $already_linked = array();
184 if (preg_match_all('/<a\s[^>]*href=["\']([^"\']+)["\'][^>]*>/i', $content, $matches)) {
185 $already_linked = array_map('untrailingslashit', $matches[1]);
186 }
187
188 // Get public post types
189 $public_post_types = get_post_types(array('public' => true), 'names');
190
191 // Extract significant words from content for search query
192 $content_words = array_filter(
193 str_word_count(strtolower($content_text), 1),
194 function($word) {
195 return strlen($word) >= 4 && !in_array($word, self::$stop_words, true);
196 }
197 );
198 // Get top frequent words for search
199 $word_freq = array_count_values($content_words);
200 arsort($word_freq);
201 $search_terms = implode(' ', array_slice(array_keys($word_freq), 0, 5));
202
203 // Two-pass query approach:
204 // Pass 1: Search by content relevance using WordPress search
205 // Pass 2: Recent posts as fallback
206 $candidate_ids = array();
207
208 // Pass 1: Relevance-based search
209 if (!empty($search_terms)) {
210 $search_query = new WP_Query(array(
211 'post__not_in' => array($post_id),
212 'post_status' => 'publish',
213 'posts_per_page' => 100,
214 'post_type' => array_values($public_post_types),
215 's' => $search_terms,
216 'no_found_rows' => true,
217 'fields' => 'ids',
218 'meta_query' => array(
219 'relation' => 'OR',
220 array(
221 'key' => '_metasync_robots_index',
222 'compare' => 'NOT EXISTS',
223 ),
224 array(
225 'key' => '_metasync_robots_index',
226 'value' => 'noindex',
227 'compare' => '!=',
228 ),
229 ),
230 ));
231 $candidate_ids = $search_query->posts;
232 }
233
234 // Pass 2: Fill up to 200 candidates with recent posts
235 $existing_count = count($candidate_ids);
236 if ($existing_count < 200) {
237 $exclude_ids = array_merge(array($post_id), $candidate_ids);
238 $recent_query = new WP_Query(array(
239 'post__not_in' => $exclude_ids,
240 'post_status' => 'publish',
241 'posts_per_page' => 200 - $existing_count,
242 'post_type' => array_values($public_post_types),
243 'orderby' => 'modified',
244 'order' => 'DESC',
245 'no_found_rows' => true,
246 'fields' => 'ids',
247 'meta_query' => array(
248 'relation' => 'OR',
249 array(
250 'key' => '_metasync_robots_index',
251 'compare' => 'NOT EXISTS',
252 ),
253 array(
254 'key' => '_metasync_robots_index',
255 'value' => 'noindex',
256 'compare' => '!=',
257 ),
258 ),
259 ));
260 $candidate_ids = array_merge($candidate_ids, $recent_query->posts);
261 }
262
263 if (empty($candidate_ids)) {
264 $data = array(
265 'suggestions' => array(),
266 'yoast_premium_active' => class_exists('WPSEO_Premium'),
267 'rankmath_active' => defined('RANK_MATH_VERSION'),
268 );
269 set_transient($cache_key, $data, HOUR_IN_SECONDS);
270 return new WP_REST_Response($data, 200);
271 }
272
273 // Batch pre-fetch: prime the post meta cache for all candidates at once
274 update_meta_cache('post', $candidate_ids);
275
276 // Batch pre-fetch: prime the term cache for all candidates (tags)
277 update_object_term_cache($candidate_ids, array_values($public_post_types));
278
279 // Now loop through candidates - meta and term queries will hit cache, not DB
280 $suggestions = array();
281
282 foreach ($candidate_ids as $candidate_id) {
283 $candidate_post = get_post($candidate_id);
284 if (!$candidate_post) {
285 continue;
286 }
287
288 $candidate_title = $candidate_post->post_title;
289 $candidate_url = get_permalink($candidate_id);
290
291 // Skip if already linked
292 if (in_array(untrailingslashit($candidate_url), $already_linked, true)) {
293 continue;
294 }
295
296 // Build match phrases with types for relevance scoring
297 // Priority: full title > focus keyword > tags > bigrams
298 $match_entries = array();
299
300 // Full title match (highest priority)
301 if (strlen($candidate_title) >= 3) {
302 $match_entries[] = array('phrase' => $candidate_title, 'type' => 'full_title');
303 }
304
305 // Focus keyword match
306 $candidate_focus = get_post_meta($candidate_id, '_metasync_focus_keyword', true);
307 if (!empty($candidate_focus) && strlen(trim($candidate_focus)) >= 3) {
308 $match_entries[] = array('phrase' => trim($candidate_focus), 'type' => 'focus_keyword');
309 }
310
311 // Tag matches
312 $candidate_tags = wp_get_post_tags($candidate_id, array('fields' => 'names'));
313 if (!empty($candidate_tags) && !is_wp_error($candidate_tags)) {
314 foreach ($candidate_tags as $tag_name) {
315 if (strlen($tag_name) >= 3) {
316 $match_entries[] = array('phrase' => $tag_name, 'type' => 'tag');
317 }
318 }
319 }
320
321 // Bigram matches from title (lowest priority, filtered)
322 $title_words = preg_split('/\s+/', $candidate_title);
323 if (count($title_words) >= 2) {
324 for ($i = 0; $i < count($title_words) - 1; $i++) {
325 $bigram = $title_words[$i] . ' ' . $title_words[$i + 1];
326 // Skip bigrams that are too short or too generic
327 if (strlen($bigram) < 6 || $this->is_generic_bigram($bigram)) {
328 continue;
329 }
330 $match_entries[] = array('phrase' => $bigram, 'type' => 'bigram');
331 }
332 }
333
334 // Check if any phrase appears in the current post content
335 $matched_phrase = null;
336 $match_type = null;
337 foreach ($match_entries as $entry) {
338 if (stripos($content_text, $entry['phrase']) !== false) {
339 $matched_phrase = $entry['phrase'];
340 $match_type = $entry['type'];
341 break;
342 }
343 }
344
345 if ($matched_phrase !== null) {
346 $relevance = $this->calculate_relevance_score($match_type, $matched_phrase, $candidate_title);
347 $suggestions[] = array(
348 'post_id' => $candidate_id,
349 'title' => $candidate_title,
350 'url' => $candidate_url,
351 'matched_phrase' => $matched_phrase,
352 'relevance_score' => $relevance,
353 );
354 }
355 }
356
357 // Sort by relevance score descending
358 usort($suggestions, function($a, $b) {
359 return $b['relevance_score'] <=> $a['relevance_score'];
360 });
361
362 // Trim to limit
363 $suggestions = array_slice($suggestions, 0, $limit);
364
365 // Cross-plugin detection
366 $yoast_active = class_exists('WPSEO_Premium');
367 $rankmath_active = defined('RANK_MATH_VERSION');
368
369 $data = array(
370 'suggestions' => $suggestions,
371 'yoast_premium_active' => $yoast_active,
372 'rankmath_active' => $rankmath_active,
373 );
374
375 set_transient($cache_key, $data, HOUR_IN_SECONDS);
376
377 return new WP_REST_Response($data, 200);
378 }
379
380 /**
381 * Invalidate link suggestions cache on post save
382 * Only for actual published posts, not revisions or autosaves
383 *
384 * @param int $post_id
385 * @param WP_Post $post
386 */
387 public function invalidate_cache($post_id, $post = null) {
388 // Skip revisions and autosaves
389 if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
390 return;
391 }
392
393 // Only invalidate for published posts
394 if ($post && $post->post_status !== 'publish') {
395 return;
396 }
397
398 delete_transient('metasync_link_suggestions_' . $post_id);
399 }
400 }
401