content_search = $content_search; $this->keyword_matcher = $keyword_matcher; } /** * Find deterministic internal-link suggestions from existing content titles and slugs. * * @param string $content Current editor content. * @param int $current_post_id Current editor post ID. * @return array> */ public function get_link_suggestions($content, $current_post_id) { $scan_text = $this->get_suggestion_scan_text($content); if (strlen($scan_text) < 20) { return array(); } $post_types = $this->content_search->get_searchable_post_types(); if (empty($post_types)) { return array(); } $post_statuses = array('publish'); if (current_user_can('read_private_posts')) { $post_statuses[] = 'private'; } $query = new WP_Query( array( 'post_type' => $post_types, 'post_status' => $post_statuses, 'posts_per_page' => 200, 'orderby' => 'modified', 'order' => 'DESC', 'no_found_rows' => true, 'ignore_sticky_posts' => true, ) ); $suggestions = array(); foreach ($query->posts as $post) { $best_suggestion = null; $phrases = $this->get_suggestion_phrases($post); foreach ($phrases as $phrase) { $count = $this->keyword_matcher->count_phrase_matches($scan_text, $phrase); if ($count < 1) { continue; } $rating = $this->get_suggestion_rating($post, $phrase, $count); $suggestion = array( 'keyword' => $phrase, 'count' => $count, 'rating' => $rating['label'], 'ratingScore' => $rating['score'], 'specificityScore' => $this->get_phrase_specificity_score($phrase), 'targetContentCount' => $rating['targetContentCount'], 'id' => (int) $post->ID, 'title' => html_entity_decode(get_the_title($post), ENT_QUOTES, get_bloginfo('charset')), 'type' => $this->content_search->get_post_type_label($post->post_type), 'postType' => $post->post_type, 'url' => get_permalink($post), 'isCurrent' => absint($current_post_id) === (int) $post->ID, 'date' => get_the_date('', $post), 'excerpt' => $this->content_search->get_result_excerpt($post), ); if (!$best_suggestion || $this->compare_suggestions($suggestion, $best_suggestion) < 0) { $best_suggestion = $suggestion; } } if ($best_suggestion) { $suggestions[] = $best_suggestion; } } wp_reset_postdata(); usort( $suggestions, function ($a, $b) { return $this->compare_suggestions($a, $b); } ); return array_slice($suggestions, 0, 20); } /** * Compare suggestions with phrase specificity ahead of raw frequency. * * @param array $a First suggestion. * @param array $b Second suggestion. * @return int */ private function compare_suggestions($a, $b) { if ($a['specificityScore'] !== $b['specificityScore']) { return $b['specificityScore'] <=> $a['specificityScore']; } if ($a['ratingScore'] !== $b['ratingScore']) { return $b['ratingScore'] <=> $a['ratingScore']; } if ($a['count'] !== $b['count']) { return $b['count'] <=> $a['count']; } return strlen($b['keyword']) <=> strlen($a['keyword']); } /** * Score phrase specificity so long-tail matches outrank generic single words. * * @param string $phrase Suggested keyword phrase. * @return int */ private function get_phrase_specificity_score($phrase) { $words = preg_split('/\s+/', trim((string) $phrase)); $word_count = is_array($words) ? count(array_filter($words)) : 0; if ($word_count >= 4) { return 400 + strlen($phrase); } if (3 === $word_count) { return 300 + strlen($phrase); } if (2 === $word_count) { return 200 + strlen($phrase); } return strlen($phrase); } /** * Rate a suggestion using deterministic match strength signals. * * @param WP_Post $post Target post. * @param string $phrase Suggested keyword. * @param int $count Keyword occurrence count in current article. * @return array{label: string, score: int, targetContentCount: int} */ private function get_suggestion_rating(WP_Post $post, $phrase, $count) { $title = strtolower(html_entity_decode(get_the_title($post), ENT_QUOTES, get_bloginfo('charset'))); $slug = strtolower(str_replace(array('-', '_'), ' ', $post->post_name)); $excerpt = strtolower($this->content_search->get_result_excerpt($post)); $keyword = strtolower((string) $phrase); $word_count = str_word_count($keyword); $target_content_count = $this->keyword_matcher->count_phrase_matches($this->get_target_content_scan_text($post), $phrase); $score = 0; if ($keyword && false !== strpos($title, $keyword)) { $score += 35; } if ($keyword && false !== strpos($slug, $keyword)) { $score += 20; } if ($keyword && false !== strpos($excerpt, $keyword)) { $score += 15; } if ($target_content_count > 0) { $score += 10; $score += min(20, $target_content_count * 4); } $score += min(20, max(0, (int) $count) * 5); $score += min(20, max(0, $word_count - 1) * 5); if ($keyword === $title || $keyword === $slug) { $score += 15; } if ($score >= 85) { return array('label' => __('Ideal', '7sky-internal-post-linker'), 'score' => $score, 'targetContentCount' => $target_content_count); } if ($score >= 65) { return array('label' => __('Best', '7sky-internal-post-linker'), 'score' => $score, 'targetContentCount' => $target_content_count); } if ($score >= 45) { return array('label' => __('Good', '7sky-internal-post-linker'), 'score' => $score, 'targetContentCount' => $target_content_count); } return array('label' => __('Normal', '7sky-internal-post-linker'), 'score' => $score, 'targetContentCount' => $target_content_count); } /** * Build plain target article text used only for server-side relevance scoring. * * @param WP_Post $post Target post. * @return string */ private function get_target_content_scan_text(WP_Post $post) { $content = wp_strip_all_tags(strip_shortcodes((string) $post->post_content)); $content = html_entity_decode($content, ENT_QUOTES, get_bloginfo('charset')); return preg_replace('/\s+/', ' ', trim($content)); } /** * Strip linked text and markup so suggestions do not target existing links. * * @param string $content Current editor content. * @return string */ private function get_suggestion_scan_text($content) { $content = preg_replace('#]*>.*?#is', ' ', $content); $content = wp_strip_all_tags(strip_shortcodes((string) $content)); $content = html_entity_decode($content, ENT_QUOTES, get_bloginfo('charset')); return preg_replace('/\s+/', ' ', trim($content)); } /** * Build useful non-AI match phrases from a target post. * * @param WP_Post $post Target post. * @return string[] */ private function get_suggestion_phrases(WP_Post $post) { $phrases = array(); $title = html_entity_decode(get_the_title($post), ENT_QUOTES, get_bloginfo('charset')); $slug = str_replace(array('-', '_'), ' ', $post->post_name); foreach (array($title, $slug) as $phrase) { $phrase = preg_replace('/\s+/', ' ', trim(wp_strip_all_tags((string) $phrase))); if ($this->keyword_matcher->is_linkworthy_phrase($phrase)) { $phrases[strtolower($phrase)] = $phrase; } foreach ($this->keyword_matcher->get_title_phrase_candidates($phrase) as $candidate) { $phrases[strtolower($candidate)] = $candidate; } } return array_values($phrases); } }