PluginProbe
SEO Engine – Smart SEO with AI, Schema & Redirection for WordPress / 0.9.4
SEO Engine – Smart SEO with AI, Schema & Redirection for WordPress v0.9.4
0.9.4 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 0.8.6 0.8.5 0.8.4 0.8.2 0.8.1 0.7.9 0.8.0 0.7.7 0.7.8 0.7.6 0.7.5 0.7.4 0.7.3 0.7.2 0.7.1 0.7.0 0.6.5 All 88 releases
seo-engine / classes / modules / suggestions.php

suggestions.php in SEO Engine – Smart SEO with AI, Schema & Redirection for WordPress 0.9.4, at classes/modules/suggestions.php

231 lines 10.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 class Meow_MWSEO_Modules_Suggestions
4 {
5 // TODO: Migrate $meta_key_seo_title to '_mwseo_title' and $meta_key_seo_excerpt to '_mwseo_excerpt'
6 public static function prompt($post, $field, $customContent = null, $max_tokens = 500, $meta_key_seo_title = '_kiss_seo_title', $meta_key_seo_excerpt = '_kiss_seo_excerpt', $core = null ){
7 // Get the global core instance if not provided
8 if ( is_null( $core ) ) {
9 global $mwseo_core;
10 $core = $mwseo_core;
11 // Fallback if global is not set (shouldn't happen in normal operation)
12 if ( is_null( $core ) ) {
13 throw new Exception( 'SEO Engine Core is not initialized.' );
14 }
15 }
16 global $mwai;
17
18 if (is_null( $mwai ) || !isset( $mwai ) ) {
19 $core->log( "⚠️ Missing AI Engine." );
20 return false;
21 }
22
23 $language = $core->get_post_language_name( $post->ID );
24 $ai_keywords = $core->get_option( 'ai_keywords', false );
25
26 $originalContent = $customContent?? Meow_MWSEO_Modules_Suggestions::get_post_sample_context( $post, $core );
27 $prompt = "";
28 $max_tokens = $max_tokens;
29 $expected_size = 0;
30
31
32
33 switch ($field){
34 //EDIT FIELDS
35 case 'title_not_unique':
36 case 'title_missing':
37 case 'title':
38 $prompt = sprintf( "Given the original title: \"%s\" and a sample from the post content : \"%s\", formulate a new, captivating, SEO-optimized title.", $post->post_title, $originalContent );
39 $expected_size = 80;
40 break;
41 case 'excerpt_missing':
42 case 'excerpt':
43 $prompt = sprintf( "Given the original excerpt: \"%s\" and a sample from the post content : \"%s\", write a concise, engaging and SEO-friendly excerpt. Ideally between 100 and 140 characters. It should be absolutely CONCISE and LESS than 160 characters.", $post->post_excerpt, $originalContent );
44 $expected_size = 160;
45 break;
46 case 'title_seo_length':
47 case 'seo_title':
48 $prompt = sprintf( "For the website \"%s\". Given the original SEO title: \"%s\" and a sample from the post content : \"%s\", create a new, SEO-optimized title that will attract more clicks. The format should be \"<short_title_keywords> | <site name>\". Ideally between 40 and 50 characters. It should be absolutely CONCISE and LESS than 70 characters.", get_bloginfo( 'name' ), get_post_meta( $post->ID, $meta_key_seo_title, true ), $originalContent );
49 $expected_size = 60;
50 break;
51 case 'excerpt_seo_length':
52 case 'seo_excerpt':
53 $prompt = sprintf( "For the website \"%s\". Given the original SEO excerpt: \"%s\" and a sample from the post content : \"%s\", write a new, SEO-friendly excerpt that will entice readers to click. The most important keywords should be at the beginning of the excerpt. Ideally between 80 and 100 characters. It should be absolutely CONCISE and LESS than 120 characters.", get_bloginfo( 'name' ), get_post_meta( $post->ID, $meta_key_seo_excerpt, true ) , $originalContent );
54 $expected_size = 100;
55 break;
56 case 'slug_words':
57 case 'slug_length':
58 case 'slug':
59 $prompt = sprintf( "Given the original slug: \"%s\" and a sample from the post content : \"%s\". Create a new, SEO-friendly 4 words slug that is less than 64 chars in the format <keyword_1>-<keyword_2>-<keyword_3>-<keyword_4>", $post->post_name, $originalContent );
60 $expected_size = 60;
61 break;
62
63 //MAGIC FIXES
64
65 case 'magic_fix_images_missing_alt_text':
66 $prompt = sprintf( "Given the original title: \"%s\" and paragraph above the image : \"%s\", create a new, SEO-friendly description that will add alt text to the image. (one sentence with 5 to 10 words max). New alt text could be :", $post->post_title, $originalContent );
67 break;
68 case 'links_missing_internal':
69
70 // Get keywords from the post.
71 $keywords = [];
72 if ( $ai_keywords ) {
73 $keywords = get_post_meta( $post->ID, '_mwseo_keywords', true );
74 }
75
76 if ( empty( $keywords ) || !is_array( $keywords ) ) {
77 $keywords_prompt = sprintf(" Givent the content of the post : \"%s\", identify the main keywords that are relevant to the post, they will be used to search the website for realted posts using WordPress search. Return a JSON array with the keywords, like this : {\"keywords\": [\"keyword1\", \"keyword2\", \"keyword3\"]}.", $originalContent );
78 $keywords_json = $mwai->simpleJsonQuery( $keywords_prompt );
79 $keywords = $keywords_json['keywords'] ?? [];
80 }
81
82 if ( empty( $keywords ) ) {
83 $core->log( "⚠️ No keywords found for the post." );
84 return false;
85 }
86
87 // Gather the posts that match the keywords, in the post's own language.
88 $posts = get_posts( $core->apply_language_filter( [
89 's' => implode( ' ', $keywords ),
90 'post_type' => 'any',
91 'post_status' => 'publish',
92 'numberposts' => 5,
93 ], $core->get_post_language_slug( $post->ID ) ) );
94
95 if ( empty( $posts ) ) {
96 $core->log( "⚠️ No posts found for the keywords." );
97 return false;
98 }
99
100 $posts_list = [];
101 foreach ( $posts as $related_post ) {
102 // Skip the current post.
103 if ( $related_post->ID === $post->ID ) {
104 continue;
105 }
106
107 $posts_list[] = sprintf( "<a href=\"%s\" target=\"_blank\">%s</a> (Excerpt: \"%s\")", get_permalink( $related_post->ID ), $related_post->post_title, wp_trim_words( $related_post->post_content, 20 ) );
108 }
109
110 if ( empty( $posts_list ) ) {
111 $core->log( "⚠️ No related posts found for the keywords." );
112 return false;
113 }
114
115 // Create the prompt.
116 $prompt = sprintf( "Given the original title: \"%s\" and a sample from the post content : \"%s\", create a new, SEO-friendly paragraph \"Check out our other articles\" that will add a few internal links to related posts on the website. The format should be natural, very human, something along \"We are also talking about [keyword] on %s\". Do NOT include links that are not provided, only use the one provided here. The Excerpt is just for you to have context about these posts, do NOT include it in the paragraphe. Include the links using the <a> element as provided.", $post->post_title, $originalContent, implode( ', ', $posts_list ) );
117 break;
118
119 case 'links_missing_external':
120 $prompt = sprintf( "Given the original title: \"%s\" and the post content: \"%s\", identify 2-3 existing text phrases from the content that would benefit from external Wikipedia links. For each phrase, find a relevant Wikipedia article. Return ONLY the HTML with the linked text using this format: <a href=\"[wikipedia_url]\" target=\"_blank\">[exact_phrase_from_content]</a> (one per line). The phrases MUST exist in the original content exactly as written. Choose phrases that are concepts, topics, or terms that readers would want to learn more about.", $post->post_title, $originalContent );
121 break;
122
123 //DEFAULT
124 default:
125 $core->log( "⚠️ Unknown field ($field), can't prompt AI." );
126 return false;
127 }
128
129 if ( $ai_keywords ) {
130 $keywords = get_post_meta( $post->ID, '_mwseo_keywords', true );
131 if ( !empty( $keywords ) && is_array( $keywords ) ) {
132 $prompt = sprintf( '%s. The user wants this keywords to be what guides your suggestion : %s.', $prompt, implode(', ', $keywords) );
133 }
134 }
135
136 $prompt = sprintf( '<instructions>Reply only with the asked content, no other text or explanations, nothing else. Your reply should be in %s.</instructions> <prompt>%s</prompt>', $language, $prompt );
137
138 try {
139 $ai_suggestion = $mwai->simpleTextQuery( $prompt, [ 'scope' => 'seo' ] );
140 $ai_suggestion = Meow_MWSEO_Modules_Suggestions::verify_ai_suggestion( $ai_suggestion, $expected_size, $language, $core );
141 } catch ( Exception $e ) {
142 $core->log( "⚠️ AI Engine Error: " . $e->getMessage() );
143 throw new Exception( "AI Engine failed to generate suggestion. Please check your AI Engine configuration. Error: " . $e->getMessage() );
144 }
145
146
147 //sanitize ai suggestion if needed
148 switch ($field){
149 case 'slug_length':
150 case 'slug_words':
151 case 'slug':
152 $ai_suggestion = sanitize_title( $ai_suggestion );
153 break;
154
155 case 'links_missing_internal':
156 case 'links_missing_external':
157 // For internal links, convert to block editor format (appended as new paragraph).
158 // For external links, keep raw HTML (used for search/replace).
159 if ( $field === 'links_missing_internal' ) {
160 $ai_suggestion = sprintf( "<!-- wp:paragraph -->\n<p>%s</p>\n<!-- /wp:paragraph -->", $ai_suggestion );
161 }
162 break;
163 case 'excerpt_seo_length':
164 case 'seo_excerpt':
165 case 'excerpt_missing':
166 case 'excerpt':
167 $ai_suggestion = preg_replace('/.*\n/', '', $ai_suggestion, 1);
168 default:
169 break;
170 }
171
172 return $ai_suggestion;
173 }
174
175 public static function verify_ai_suggestion( $ai_suggestion, $expected_size, $language, $core = null ) {
176 // Get the global core instance if not provided
177 if ( is_null( $core ) ) {
178 global $mwseo_core;
179 $core = $mwseo_core;
180 }
181 $is_auto_correct_enabled = get_option( 'mwseo_options', null )[ 'ai_auto_correct' ] ?? false;
182
183 if ( strlen( $ai_suggestion ) > $expected_size && $expected_size > 0 && $is_auto_correct_enabled) {
184 $core->log( "⚠️ AI Suggestion too long." );
185
186 $prompt = sprintf('In %s, simplistically paraphrase the following "%s" (currently a %d characters string) into a string with less than %d characters, and ensure that its essential concepts and format are retained. Only the reduced form is required. Shortened string is: ', $language, $ai_suggestion, strlen($ai_suggestion), $expected_size);
187
188 global $mwai;
189 $ai_suggestion = $mwai->simpleTextQuery( $prompt, [ 'scope' => 'seo' ] );
190 }
191
192 return $ai_suggestion;
193 }
194
195 public static function get_post_sample_context( $post, $core = null ){
196 // Get the global core instance if not provided
197 if ( is_null( $core ) ) {
198 global $mwseo_core;
199 $core = $mwseo_core;
200 }
201
202 // Live content (when enabled) is applied via the mwseo_post_content filter.
203 $content = apply_filters( 'mwseo_post_content', strip_tags( $post->post_content ), $post, true );
204
205 $words = str_word_count( $content, 1 );
206 $words_count = count( $words );
207
208 // If the post is less than 300 words, get the whole content.
209 if ( $words_count < 300 ) {
210 return $content;
211 }
212
213 // Get a total of 300 words from the post, 100 from the beginning, 100 from the middle and 100 from the end.
214 $words_per_section = 200;
215 $words_per_section = $words_per_section > $words_count ? $words_count : $words_per_section;
216 $words_per_section = $words_per_section < 50 ? 50 : $words_per_section;
217
218 $words_beginning = array_slice( $words, 0, $words_per_section );
219 $words_middle = array_slice( $words, floor( $words_count / 2 ) - floor( $words_per_section / 2 ), $words_per_section );
220 $words_end = array_slice( $words, $words_count - $words_per_section, $words_per_section );
221
222 $context = sprintf(
223 "Beginning of content : \"%s\". Middle of content : \"%s\". End of content : \"%s\".",
224 implode( ' ', $words_beginning ),
225 implode( ' ', $words_middle ),
226 implode( ' ', $words_end )
227 );
228
229 return $context;
230 }
231 }