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 / admin / class-metasync-link-suggestions.php

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

391 lines 14.3 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 // Strip block comments and HTML tags to get clean text for matching
169 $content = $post->post_content;
170 $content_text = wp_strip_all_tags(strip_shortcodes($content));
171
172 // Build list of already-linked URLs
173 $already_linked = array();
174 if (preg_match_all('/<a\s[^>]*href=["\']([^"\']+)["\'][^>]*>/i', $content, $matches)) {
175 $already_linked = array_map('untrailingslashit', $matches[1]);
176 }
177
178 // Get public post types
179 $public_post_types = get_post_types(array('public' => true), 'names');
180
181 // Extract significant words from content for search query
182 $content_words = array_filter(
183 str_word_count(strtolower($content_text), 1),
184 function($word) {
185 return strlen($word) >= 4 && !in_array($word, self::$stop_words, true);
186 }
187 );
188 // Get top frequent words for search
189 $word_freq = array_count_values($content_words);
190 arsort($word_freq);
191 $search_terms = implode(' ', array_slice(array_keys($word_freq), 0, 5));
192
193 // Two-pass query approach:
194 // Pass 1: Search by content relevance using WordPress search
195 // Pass 2: Recent posts as fallback
196 $candidate_ids = array();
197
198 // Pass 1: Relevance-based search
199 if (!empty($search_terms)) {
200 $search_query = new WP_Query(array(
201 'post__not_in' => array($post_id),
202 'post_status' => 'publish',
203 'posts_per_page' => 100,
204 'post_type' => array_values($public_post_types),
205 's' => $search_terms,
206 'no_found_rows' => true,
207 'fields' => 'ids',
208 'meta_query' => array(
209 'relation' => 'OR',
210 array(
211 'key' => '_metasync_robots_index',
212 'compare' => 'NOT EXISTS',
213 ),
214 array(
215 'key' => '_metasync_robots_index',
216 'value' => 'noindex',
217 'compare' => '!=',
218 ),
219 ),
220 ));
221 $candidate_ids = $search_query->posts;
222 }
223
224 // Pass 2: Fill up to 200 candidates with recent posts
225 $existing_count = count($candidate_ids);
226 if ($existing_count < 200) {
227 $exclude_ids = array_merge(array($post_id), $candidate_ids);
228 $recent_query = new WP_Query(array(
229 'post__not_in' => $exclude_ids,
230 'post_status' => 'publish',
231 'posts_per_page' => 200 - $existing_count,
232 'post_type' => array_values($public_post_types),
233 'orderby' => 'modified',
234 'order' => 'DESC',
235 'no_found_rows' => true,
236 'fields' => 'ids',
237 'meta_query' => array(
238 'relation' => 'OR',
239 array(
240 'key' => '_metasync_robots_index',
241 'compare' => 'NOT EXISTS',
242 ),
243 array(
244 'key' => '_metasync_robots_index',
245 'value' => 'noindex',
246 'compare' => '!=',
247 ),
248 ),
249 ));
250 $candidate_ids = array_merge($candidate_ids, $recent_query->posts);
251 }
252
253 if (empty($candidate_ids)) {
254 $data = array(
255 'suggestions' => array(),
256 'yoast_premium_active' => class_exists('WPSEO_Premium'),
257 'rankmath_active' => defined('RANK_MATH_VERSION'),
258 );
259 set_transient($cache_key, $data, HOUR_IN_SECONDS);
260 return new WP_REST_Response($data, 200);
261 }
262
263 // Batch pre-fetch: prime the post meta cache for all candidates at once
264 update_meta_cache('post', $candidate_ids);
265
266 // Batch pre-fetch: prime the term cache for all candidates (tags)
267 update_object_term_cache($candidate_ids, array_values($public_post_types));
268
269 // Now loop through candidates - meta and term queries will hit cache, not DB
270 $suggestions = array();
271
272 foreach ($candidate_ids as $candidate_id) {
273 $candidate_post = get_post($candidate_id);
274 if (!$candidate_post) {
275 continue;
276 }
277
278 $candidate_title = $candidate_post->post_title;
279 $candidate_url = get_permalink($candidate_id);
280
281 // Skip if already linked
282 if (in_array(untrailingslashit($candidate_url), $already_linked, true)) {
283 continue;
284 }
285
286 // Build match phrases with types for relevance scoring
287 // Priority: full title > focus keyword > tags > bigrams
288 $match_entries = array();
289
290 // Full title match (highest priority)
291 if (strlen($candidate_title) >= 3) {
292 $match_entries[] = array('phrase' => $candidate_title, 'type' => 'full_title');
293 }
294
295 // Focus keyword match
296 $candidate_focus = get_post_meta($candidate_id, '_metasync_focus_keyword', true);
297 if (!empty($candidate_focus) && strlen(trim($candidate_focus)) >= 3) {
298 $match_entries[] = array('phrase' => trim($candidate_focus), 'type' => 'focus_keyword');
299 }
300
301 // Tag matches
302 $candidate_tags = wp_get_post_tags($candidate_id, array('fields' => 'names'));
303 if (!empty($candidate_tags) && !is_wp_error($candidate_tags)) {
304 foreach ($candidate_tags as $tag_name) {
305 if (strlen($tag_name) >= 3) {
306 $match_entries[] = array('phrase' => $tag_name, 'type' => 'tag');
307 }
308 }
309 }
310
311 // Bigram matches from title (lowest priority, filtered)
312 $title_words = preg_split('/\s+/', $candidate_title);
313 if (count($title_words) >= 2) {
314 for ($i = 0; $i < count($title_words) - 1; $i++) {
315 $bigram = $title_words[$i] . ' ' . $title_words[$i + 1];
316 // Skip bigrams that are too short or too generic
317 if (strlen($bigram) < 6 || $this->is_generic_bigram($bigram)) {
318 continue;
319 }
320 $match_entries[] = array('phrase' => $bigram, 'type' => 'bigram');
321 }
322 }
323
324 // Check if any phrase appears in the current post content
325 $matched_phrase = null;
326 $match_type = null;
327 foreach ($match_entries as $entry) {
328 if (stripos($content_text, $entry['phrase']) !== false) {
329 $matched_phrase = $entry['phrase'];
330 $match_type = $entry['type'];
331 break;
332 }
333 }
334
335 if ($matched_phrase !== null) {
336 $relevance = $this->calculate_relevance_score($match_type, $matched_phrase, $candidate_title);
337 $suggestions[] = array(
338 'post_id' => $candidate_id,
339 'title' => $candidate_title,
340 'url' => $candidate_url,
341 'matched_phrase' => $matched_phrase,
342 'relevance_score' => $relevance,
343 );
344 }
345 }
346
347 // Sort by relevance score descending
348 usort($suggestions, function($a, $b) {
349 return $b['relevance_score'] <=> $a['relevance_score'];
350 });
351
352 // Trim to limit
353 $suggestions = array_slice($suggestions, 0, $limit);
354
355 // Cross-plugin detection
356 $yoast_active = class_exists('WPSEO_Premium');
357 $rankmath_active = defined('RANK_MATH_VERSION');
358
359 $data = array(
360 'suggestions' => $suggestions,
361 'yoast_premium_active' => $yoast_active,
362 'rankmath_active' => $rankmath_active,
363 );
364
365 set_transient($cache_key, $data, HOUR_IN_SECONDS);
366
367 return new WP_REST_Response($data, 200);
368 }
369
370 /**
371 * Invalidate link suggestions cache on post save
372 * Only for actual published posts, not revisions or autosaves
373 *
374 * @param int $post_id
375 * @param WP_Post $post
376 */
377 public function invalidate_cache($post_id, $post = null) {
378 // Skip revisions and autosaves
379 if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
380 return;
381 }
382
383 // Only invalidate for published posts
384 if ($post && $post->post_status !== 'publish') {
385 return;
386 }
387
388 delete_transient('metasync_link_suggestions_' . $post_id);
389 }
390 }
391